diff --git a/.cursor/rules/40-iam-sso.mdc b/.cursor/rules/40-iam-sso.mdc index 6fae8d2..1c5ffa0 100644 --- a/.cursor/rules/40-iam-sso.mdc +++ b/.cursor/rules/40-iam-sso.mdc @@ -8,7 +8,7 @@ alwaysApply: true مستندها: - `docs/iam-connection.md` — **نحوه ارتباط**: سه کانال داده و رفتار در خطا -- `docs/iam-sso.md` — پیکربندی، ابطال نشست، محدودیت‌ها +- `docs/iam-sso.md` — پیکربندی، ورود مشترک با همکار، ابطال نشست - مرجع تصویری اندپوینت‌های IAM: `/docs` (Scalar) ## اصل غیرقابل‌مذاکره @@ -16,10 +16,15 @@ alwaysApply: true این قابلیت **افزایشی** است. با `IAM_ENABLED=false` (پیش‌فرض) سامانه باید دقیقاً مثل قبل رفتار کند: یک `bcrypt.compare` محلی و هیچ درخواست شبکه‌ای. -- **هیچ تغییری در رابط کاربری نیست.** نه دکمه SSO، نه ریدایرکت، نه صفحه جدید. - ادمین مثل همیشه موبایل و رمز را در همین پنل وارد می‌کند. -- پاسخ `POST /api/admin/auth/login` در هر دو حالت یکسان است (`api_token`, - `roles`, …) پس پنل ادمین تفاوتی نمی‌بیند. +- فرم ورود عوض نمی‌شود. پاسخ ورود همان `api_token` قبلی است، به‌اضافهٔ + `iamBootstrapUrl` فقط وقتی IAM بلیت مرورگر داده باشد. +- وقتی IAM روشن است و `IAM_ADMIN_REDIRECT_URI` (یا یکی از + `IAM_ADMIN_REDIRECT_URIS`) با آدرس خودِ پنل یکی است، بار اول بدون توکن یک + پرش `prompt=none` به `/auth/iam/callback` می‌رود. اپ کاربر همین کار را با + `IAM_REDIRECT_URI` و `IAM_REDIRECT_URIS` می‌کند. آدرس ناشناس جایگزین آدرس + ثبت‌شده نمی‌شود و پاسخ `enabled: false` می‌ماند. +- IAM خاموش، یا redirect ادمین خالی، یعنی این پرش شروع نمی‌شود و خروج آدرس + مرکزی برنمی‌گرداند. ## نقشه فایل‌ها diff --git a/.env.example b/.env.example index f98e1c3..1828542 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,22 @@ IAM_TENANT_SLUG= # off | fallback (default, safest) | exclusive (local password no longer accepted) IAM_LOGIN_DELEGATION=fallback IAM_HTTP_TIMEOUT_MS=8000 +# صفحه callback اپ کاربر. باید در کلاینت IAM دقیقاً ثبت شود. +IAM_REDIRECT_URI= +# callbackهای اضافه (دولوپ و دامنه دیگر) با کاما. آدرس ناشناس جایگزین نمی‌شود. +IAM_REDIRECT_URIS= +# بعد از ورود محلی، مرورگر برای کوکی نشست مشترک به اینجا برمی‌گردد. +IAM_BROWSER_RETURN_URL= +# خروج سراسری فقط اگر این آدرس در post_logout کلاینت ثبت شده باشد برمی‌گردد. +IAM_POST_LOGOUT_REDIRECT_URI= +# مقصدهای مجاز خروج اپ کاربر، با کاما. باید عیناً در post_logout کلاینت باشند. +IAM_ALLOWED_RETURN_URLS= +# پنل ادمین. خالی یعنی بررسی بی‌صدای ورود در پنل انجام نمی‌شود. +IAM_ADMIN_REDIRECT_URI= +IAM_ADMIN_REDIRECT_URIS= +IAM_ADMIN_BROWSER_RETURN_URL= +IAM_ADMIN_POST_LOGOUT_REDIRECT_URI= +IAM_ADMIN_ALLOWED_RETURN_URLS= # Revocable admin sessions (AdminSession table). # diff --git a/apps/admin-panel/src/layouts/components/TheNavbar.vue b/apps/admin-panel/src/layouts/components/TheNavbar.vue index d01595a..1677cdc 100644 --- a/apps/admin-panel/src/layouts/components/TheNavbar.vue +++ b/apps/admin-panel/src/layouts/components/TheNavbar.vue @@ -104,9 +104,9 @@ const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'ک const initial = computed(() => displayName.value.trim().charAt(0) || 'ک'); const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission'))); -function onLogout() { - auth.logout(); - router.push('/auth/login'); +async function onLogout() { + const left = await auth.logout(); + if (!left) router.push('/auth/login'); } diff --git a/apps/admin-panel/src/router/index.ts b/apps/admin-panel/src/router/index.ts index dabcf45..29e10ef 100644 --- a/apps/admin-panel/src/router/index.ts +++ b/apps/admin-panel/src/router/index.ts @@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import { adminRoute, fullPageAdminRoute } from './admin'; import { branchRoute, fullPageBranchRoute } from './branch'; -import { isBranch, tokenName, userName } from '../utils'; +import { iamAppUrl, isBranch, tokenName, userName } from '../utils'; import { useUsersStore } from '../stores/users'; import api from '../api/client'; @@ -25,6 +25,12 @@ const router = createRouter({ component: () => import('../layouts/full-page/FullPage.vue'), children: [ ...(isBranch() ? fullPageBranchRoute : fullPageAdminRoute), + { + path: '/auth/iam/callback', + name: 'iam-callback', + component: () => import('../views/auth/IamCallback.vue'), + meta: { rule: 'editor' }, + }, { path: '/pages/error-404', name: 'page-error-404', @@ -68,7 +74,7 @@ router.afterEach(() => { }); // legacy router.js:156-161 -const noneAuthUrl = ['/auth/login', '/auth/forgot-password', '/auth/register', '/reg']; +const noneAuthUrl = ['/auth/login', '/auth/forgot-password', '/auth/register', '/reg', '/auth/iam/callback']; /** * Port of legacy getPermission() (router.js:163-182). @@ -109,6 +115,21 @@ async function getPermission(): Promise { * equivalent and produces the same user-visible result. */ router.beforeEach(async (to) => { + if (!localStorage.getItem(tokenName) && to.path !== '/auth/iam/callback' && sessionStorage.getItem('gb_admin_iam_sso_checked') !== '1') { + sessionStorage.setItem('gb_admin_iam_sso_checked', '1'); + try { + const { data } = await api.get('admin/auth/sso/start', { + params: { redirect_uri: iamAppUrl('auth/iam/callback') }, + }); + if (data?.enabled && data.authorize_url) { + window.location.assign(data.authorize_url); + return false; + } + } catch { + // سامانه هویت برای این پنل تنظیم نشده + } + } + if (noneAuthUrl.indexOf(to.path) > -1) { return true; } diff --git a/apps/admin-panel/src/stores/auth.ts b/apps/admin-panel/src/stores/auth.ts index 50e83f6..9ac0e42 100644 --- a/apps/admin-panel/src/stores/auth.ts +++ b/apps/admin-panel/src/stores/auth.ts @@ -1,7 +1,7 @@ // Ported from legacy henza_admin/src/store/auth/moduleAuth.js import { defineStore } from 'pinia'; import api from '../api/client'; -import { tokenName, userName } from '../utils'; +import { iamAppUrl, tokenName, userName } from '../utils'; import { useUsersStore } from './users'; import type { UserInfo } from './users'; @@ -67,6 +67,9 @@ export const useAuthStore = defineStore('auth', { delete persisted.ut; this.inject(persisted); + if (data.iamBootstrapUrl) { + window.location.assign(data.iamBootstrapUrl); + } return persisted; }, @@ -83,13 +86,28 @@ export const useAuthStore = defineStore('auth', { * its module-scoped `roles` survived a session change (bug #2). Clearing * both storage and the roles store is the fix for that. */ - logout() { + async logout(): Promise { + let central: string | null = null; + try { + const { data } = await api.get('admin/auth/sso/logout-url', { + params: { return_url: iamAppUrl('auth/login') }, + }); + central = data?.logout_url ?? null; + } catch { + central = null; + } localStorage.removeItem(tokenName); localStorage.removeItem(userName); localStorage.removeItem('login'); + sessionStorage.removeItem('gb_admin_iam_sso_checked'); const users = useUsersStore(); users.deleteRoles(); users.deleteUserInfo(); + if (central) { + window.location.assign(central); + return true; + } + return false; }, }, }); diff --git a/apps/admin-panel/src/utils/index.ts b/apps/admin-panel/src/utils/index.ts index 5d397b6..cd8cd1c 100644 --- a/apps/admin-panel/src/utils/index.ts +++ b/apps/admin-panel/src/utils/index.ts @@ -15,5 +15,12 @@ export function isBranch(): boolean { // Legacy stored the token under a different localStorage key per mode. // See henza_admin/src/axios.js:6 and router.js:173-174,188. +/** آدرس همین پنل، با پیشوند /branch در حالت شعبه. */ +export function iamAppUrl(path: string) { + const base = import.meta.env.BASE_URL || '/'; + const prefix = base.endsWith('/') ? base : `${base}/`; + return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`; +} + export const tokenName = isBranch() ? 'branch_token' : 'token'; export const userName = isBranch() ? 'branch_user' : 'user'; diff --git a/apps/admin-panel/src/views/auth/IamCallback.vue b/apps/admin-panel/src/views/auth/IamCallback.vue new file mode 100644 index 0000000..f43c78b --- /dev/null +++ b/apps/admin-panel/src/views/auth/IamCallback.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/api/src/common/env.ts b/apps/api/src/common/env.ts index 4a8668d..9af7b70 100644 --- a/apps/api/src/common/env.ts +++ b/apps/api/src/common/env.ts @@ -26,3 +26,21 @@ export function envOrNull(name: string): string | null { const trimmed = typeof raw === 'string' ? raw.trim() : ''; return trimmed === '' ? null : trimmed; } + +/** Comma-separated env list. Blank entries are dropped. */ +export function envList(name: string): string[] { + const raw = envOrNull(name); + if (!raw) return []; + return raw.split(',').map((item) => item.trim()).filter(Boolean); +} + +/** + * Exact string match. A requested URI that is not in the list is refused + * (null) instead of being replaced by another app's address. + * With no request, the first configured value is used. + */ +export function pickExactUri(requested: string | undefined, allowed: Array): string | null { + const unique = [...new Set(allowed.filter((item): item is string => Boolean(item)))]; + if (requested) return unique.includes(requested) ? requested : null; + return unique[0] ?? null; +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts index 941452a..0c14795 100644 --- a/apps/api/src/modules/auth/auth.controller.ts +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 7614430..b35e42c 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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([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 !== ''); +} diff --git a/apps/api/src/modules/customer-auth/customer-auth.controller.ts b/apps/api/src/modules/customer-auth/customer-auth.controller.ts index 3b6a515..baf94ff 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.controller.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.controller.ts @@ -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; +} diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts index 0f23742..948b9d2 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -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([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( + 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, diff --git a/apps/api/src/modules/iam/iam.service.spec.ts b/apps/api/src/modules/iam/iam.service.spec.ts index 5045fbb..84c362a 100644 --- a/apps/api/src/modules/iam/iam.service.spec.ts +++ b/apps/api/src/modules/iam/iam.service.spec.ts @@ -149,6 +149,7 @@ describe('IamService', () => { firstName: 'رضا', lastName: 'کاظمی', sessionId: 'sid-9', + browserTicket: null, }); }); diff --git a/apps/api/src/modules/iam/iam.service.ts b/apps/api/src/modules/iam/iam.service.ts index 82d823f..00e135b 100644 --- a/apps/api/src/modules/iam/iam.service.ts +++ b/apps/api/src/modules/iam/iam.service.ts @@ -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; + 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 { + 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; } diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index 7419b7c..04ace3c 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -279,6 +279,12 @@ export interface ReportBalance { arz: string; mandeh: string; } +function iamAppUrl(path: string) { + const base = import.meta.env.BASE_URL || '/' + const prefix = base.endsWith('/') ? base : `${base}/` + return `${window.location.origin}${prefix}${path.replace(/^\//, '')}` +} + export const api = { listProducts: () => request('/catalog/products'), /** @@ -298,11 +304,27 @@ export const api = { * already-used code is a 401. */ verifyOtp: (username: string, password: string) => - request<{ token: string; customer: { id: string; mobile: string } }>( + request<{ token: string; customer: { id: string; mobile: string }; iamBootstrapUrl?: string }>( '/customer/auth/verify-otp', { method: 'POST', body: JSON.stringify({ username, password }) }, ), + ssoStart: () => + request<{ enabled: boolean; authorize_url: string | null }>( + `/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`, + ), + + ssoCallback: (code: string, state: string) => + request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', { + method: 'POST', + body: JSON.stringify({ code, state }), + }), + + ssoLogoutUrl: () => + request<{ logout_url: string | null }>( + `/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`, + ), + // The three reads below identify the customer by TOKEN. They replaced // /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from // the URL and let anyone read anyone (SEC-08). diff --git a/apps/user-app-v2/src/router/index.ts b/apps/user-app-v2/src/router/index.ts index a2c5181..29bd1d6 100644 --- a/apps/user-app-v2/src/router/index.ts +++ b/apps/user-app-v2/src/router/index.ts @@ -27,6 +27,7 @@ export const router = createRouter({ { path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } }, { path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') }, { path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } }, + { path: '/auth/iam/callback', name: 'iamCallback', component: () => import('../views/user/IamCallback.vue'), meta: { layout: 'FullPage' } }, { path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') }, { path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } }, { @@ -96,6 +97,19 @@ router.beforeEach(async (to) => { const session = useSessionStore(); const nav = useNavStore(); + if (!session.isLoggedIn && to.name !== 'iamCallback' && sessionStorage.getItem('gb_iam_sso_checked') !== '1') { + sessionStorage.setItem('gb_iam_sso_checked', '1'); + try { + const started = await api.ssoStart(); + if (started.enabled && started.authorize_url) { + window.location.assign(started.authorize_url); + return false; + } + } catch { + // سامانه هویت خاموش است یا در دسترس نیست + } + } + if (to.name === 'login' && session.isLoggedIn) { return { name: 'home' }; } diff --git a/apps/user-app-v2/src/views/user/DashboardView.vue b/apps/user-app-v2/src/views/user/DashboardView.vue index 058368a..0891b2a 100644 --- a/apps/user-app-v2/src/views/user/DashboardView.vue +++ b/apps/user-app-v2/src/views/user/DashboardView.vue @@ -283,9 +283,20 @@ function goToDetails(id: number) { if (id === 6) router.push({ name: 'report' }) } -function logOut() { - session.logout() +async function logOut() { data.notify = false + let central: string | null = null + try { + central = (await api.ssoLogoutUrl()).logout_url + } catch { + central = null + } + session.logout() + sessionStorage.removeItem('gb_iam_sso_checked') + if (central) { + window.location.assign(central) + return + } router.push({ name: 'home' }) } diff --git a/apps/user-app-v2/src/views/user/IamCallback.vue b/apps/user-app-v2/src/views/user/IamCallback.vue new file mode 100644 index 0000000..8295d6c --- /dev/null +++ b/apps/user-app-v2/src/views/user/IamCallback.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/user-app-v2/src/views/user/LoginView.vue b/apps/user-app-v2/src/views/user/LoginView.vue index d07d74d..7f7fa87 100644 --- a/apps/user-app-v2/src/views/user/LoginView.vue +++ b/apps/user-app-v2/src/views/user/LoginView.vue @@ -159,7 +159,8 @@ const sendPhone = async () => { const sendToken = async (otpCode: string) => { try { body.loading = true - const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode) + const verified = await api.verifyOtp(String(formModel.username ?? ''), otpCode) + const { token, customer } = verified session.login(token, customer.id, customer.mobile) // D21 — ask the server which kind of principal this token is, so the // expert-only entries appear for a sales expert. Failure is non-fatal: @@ -171,6 +172,10 @@ const sendToken = async (otpCode: string) => { } catch { session.setSalesExpert(false) } + if (verified.iamBootstrapUrl) { + window.location.assign(verified.iamBootstrapUrl) + return + } const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined await router.push(redirect ?? { name: 'home' }) toast.success('با موفقیت وارد شدید') diff --git a/apps/user-app/src/api/client.ts b/apps/user-app/src/api/client.ts index 149c55f..a2fc7b4 100644 --- a/apps/user-app/src/api/client.ts +++ b/apps/user-app/src/api/client.ts @@ -294,6 +294,12 @@ export interface ReportBalance { arz: string; mandeh: string; } +function iamAppUrl(path: string) { + const base = import.meta.env.BASE_URL || '/' + const prefix = base.endsWith('/') ? base : `${base}/` + return `${window.location.origin}${prefix}${path.replace(/^\//, '')}` +} + export const api = { listProducts: () => request('/catalog/products'), @@ -314,11 +320,27 @@ export const api = { * already-used code is a 401. */ verifyOtp: (username: string, password: string) => - request<{ token: string; customer: { id: string; mobile: string } }>( + request<{ token: string; customer: { id: string; mobile: string }; iamBootstrapUrl?: string }>( '/customer/auth/verify-otp', { method: 'POST', body: JSON.stringify({ username, password }) }, ), + ssoStart: () => + request<{ enabled: boolean; authorize_url: string | null }>( + `/customer/auth/sso/start?redirect_uri=${encodeURIComponent(iamAppUrl('auth/iam/callback'))}`, + ), + + ssoCallback: (code: string, state: string) => + request<{ token: string; customer: { id: string; mobile: string } }>('/customer/auth/sso/callback', { + method: 'POST', + body: JSON.stringify({ code, state }), + }), + + ssoLogoutUrl: () => + request<{ logout_url: string | null }>( + `/customer/auth/sso/logout-url?return_url=${encodeURIComponent(iamAppUrl(''))}`, + ), + // The three reads below identify the customer by TOKEN. They replaced // /customers/:id, /:id/orders and /:id/ledger-entries, which took the id from // the URL and let anyone read anyone (SEC-08). diff --git a/apps/user-app/src/router/index.ts b/apps/user-app/src/router/index.ts index 600df1c..67588dc 100644 --- a/apps/user-app/src/router/index.ts +++ b/apps/user-app/src/router/index.ts @@ -32,6 +32,7 @@ export const router = createRouter({ { path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } }, { path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') }, { path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } }, + { path: '/auth/iam/callback', name: 'iamCallback', component: () => import('../views/user/IamCallback.vue'), meta: { layout: 'FullPage' } }, { path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') }, { path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } }, { @@ -101,6 +102,19 @@ router.beforeEach(async (to) => { const session = useSessionStore(); const nav = useNavStore(); + if (!session.isLoggedIn && to.name !== 'iamCallback' && sessionStorage.getItem('gb_iam_sso_checked') !== '1') { + sessionStorage.setItem('gb_iam_sso_checked', '1'); + try { + const started = await api.ssoStart(); + if (started.enabled && started.authorize_url) { + window.location.assign(started.authorize_url); + return false; + } + } catch { + // سامانه هویت خاموش است یا در دسترس نیست + } + } + if (to.name === 'login' && session.isLoggedIn) { return { name: 'home' }; } diff --git a/apps/user-app/src/views/user/DashboardView.vue b/apps/user-app/src/views/user/DashboardView.vue index a9620f0..8ef0016 100644 --- a/apps/user-app/src/views/user/DashboardView.vue +++ b/apps/user-app/src/views/user/DashboardView.vue @@ -237,9 +237,20 @@ function goToDetails(id: number) { if (id === 6) router.push({ name: 'report' }) } -function logOut() { - session.logout() +async function logOut() { data.notify = false + let central: string | null = null + try { + central = (await api.ssoLogoutUrl()).logout_url + } catch { + central = null + } + session.logout() + sessionStorage.removeItem('gb_iam_sso_checked') + if (central) { + window.location.assign(central) + return + } router.push({ name: 'home' }) } diff --git a/apps/user-app/src/views/user/IamCallback.vue b/apps/user-app/src/views/user/IamCallback.vue new file mode 100644 index 0000000..8295d6c --- /dev/null +++ b/apps/user-app/src/views/user/IamCallback.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/user-app/src/views/user/LoginView.vue b/apps/user-app/src/views/user/LoginView.vue index e64e20d..789ecca 100644 --- a/apps/user-app/src/views/user/LoginView.vue +++ b/apps/user-app/src/views/user/LoginView.vue @@ -159,7 +159,8 @@ const sendPhone = async () => { const sendToken = async (otpCode: string) => { try { body.loading = true - const { token, customer } = await api.verifyOtp(String(formModel.username ?? ''), otpCode) + const verified = await api.verifyOtp(String(formModel.username ?? ''), otpCode) + const { token, customer } = verified session.login(token, customer.id, customer.mobile) // D21 — ask the server which kind of principal this token is, so the // expert-only entries appear for a sales expert. Failure is non-fatal: @@ -171,6 +172,10 @@ const sendToken = async (otpCode: string) => { } catch { session.setSalesExpert(false) } + if (verified.iamBootstrapUrl) { + window.location.assign(verified.iamBootstrapUrl) + return + } const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : undefined await router.push(redirect ?? { name: 'home' }) toast.success('با موفقیت وارد شدید') diff --git a/docs/iam-sso.md b/docs/iam-sso.md index 1712c28..c38be8c 100644 --- a/docs/iam-sso.md +++ b/docs/iam-sso.md @@ -12,7 +12,7 @@ | موضوع | تصمیم | | --- | --- | -| رابط کاربری | **هیچ تغییری نمی‌کند**؛ نه دکمه SSO، نه ریدایرکت، نه صفحه جدید | +| رابط کاربری | فرم ورود همان است. با IAM روشن، یک پرش بی‌صدای `prompt=none` و صفحه `/auth/iam/callback` اضافه شده تا ورود مشترک با همکار کار کند | | جای اتصال | فقط همان یک نقطه‌ای که رمز بررسی می‌شد (`AuthService.login`) | | پیش‌فرض | خاموش (`IAM_ENABLED=false`) — رفتار سامانه دقیقاً مثل امروز | | در دسترس نبودن IAM | بازگشت خودکار به بررسی رمز محلی ⇒ ورود ادمین‌ها قطع نمی‌شود | @@ -86,8 +86,9 @@ curl -X POST https://auth.example.com/api/v1/admin/clients \ }' ``` -> `redirect_uris` در این حالت استفاده نمی‌شود (جریان مرورگری نداریم) ولی -> IAM آن را اجباری می‌داند؛ یک آدرس معتبر بگذارید. +> `redirect_uris` آدرس callback فرانت است و باید دقیقاً با صفحه‌ای که مرورگر +> باز می‌کند یکی باشد. برای اپ کاربر و پنل ادمین، و برای دولوپ و پروداکشن، +> هر کدام یک خط جدا. جزئیات در بخش «ورود مشترک با همکار». ### گام ۲ — تنظیم `.env` @@ -192,7 +193,34 @@ POST /api/iam/backchannel-logout ← این آدرس را در IAM به‌عن --- -## ۶. تست +## ۶. ورود مشترک با همکار + +کوکی نشست روی خود IAM است (`iam.montest.ir`) و بین دامنه‌ها مشترک نمی‌شود. +ورود در یک سامانه فقط وقتی سامانهٔ دیگر را هم باز می‌کند که: + +1. ورود محلی بلیت مرورگر گرفته باشد و فرانت به `iamBootstrapUrl` رفته باشد. +2. سامانهٔ دوم بدون توکن محلی، `prompt=none` بزند و callback **همان صفحه** باشد. +3. همان شماره موبایل در سامانهٔ دوم از قبل حساب داشته باشد. IAM حساب gold-bank + یا همکار را نمی‌سازد. + +کلاینت gold-bank جدا از `hamkar_server` است (`goldbank_api`). آدرس‌ها عیناً، +هر کدام یک خط در فرم IAM و با کاما در env: + +| نقش | callback (آدرس‌های بازگشت) | بعد از خروج | +| --- | --- | --- | +| اپ کاربر | `IAM_REDIRECT_URI` و `IAM_REDIRECT_URIS` | `IAM_POST_LOGOUT_REDIRECT_URI` و `IAM_ALLOWED_RETURN_URLS` | +| پنل ادمین | `IAM_ADMIN_REDIRECT_URI` و `IAM_ADMIN_REDIRECT_URIS` | `IAM_ADMIN_POST_LOGOUT_REDIRECT_URI` و `IAM_ADMIN_ALLOWED_RETURN_URLS` | + +فرانت آدرس خودش را می‌فرستد (`origin` + `/auth/iam/callback`؛ پنل شعبه پیشوند +`/branch` دارد). اگر آن رشته در فهرست نباشد، بررسی بی‌صدا انجام نمی‌شود و +صفحهٔ لاگین محلی می‌ماند. خروج هم اگر مقصدش ثبت نشده باشد به IAM نمی‌رود. + +وقتی `IAM_ENABLED` خاموش است، `sso/start` مقدار `enabled: false` می‌دهد و +`sso/logout-url` مقدار `logout_url: null`. هیچ پیام خطایی برای خروج لازم نیست. + +--- + +## ۷. تست ```bash cd apps/api