@open-mercato/enterprise 0.6.6-develop.6431.1.g1037269810 → 0.6.6-develop.6450.1.b493fb430c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
3
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
3
4
  import { ScimProvisioningLog } from "../../../data/entities.js";
5
+ import { SsoConfigError } from "../../../services/ssoConfigService.js";
4
6
  import { resolveSsoAdminContext } from "../../admin-context.js";
5
7
  import { handleSsoAdminApiError } from "../../error-handler.js";
6
8
  const metadata = {
@@ -15,15 +17,22 @@ async function GET(req) {
15
17
  return NextResponse.json({ error: "ssoConfigId is required" }, { status: 400 });
16
18
  }
17
19
  const where = { ssoConfigId };
18
- if (!scope.isSuperAdmin && scope.organizationId) {
20
+ if (!scope.isSuperAdmin) {
21
+ if (!scope.organizationId) throw new SsoConfigError("Organization context is required", 403);
19
22
  where.organizationId = scope.organizationId;
20
23
  }
21
24
  const container = await createRequestContainer();
22
25
  const em = container.resolve("em");
23
- const logs = await em.find(ScimProvisioningLog, where, {
24
- orderBy: { createdAt: "desc" },
25
- limit: 50
26
- });
26
+ const logs = await findWithDecryption(
27
+ em,
28
+ ScimProvisioningLog,
29
+ where,
30
+ {
31
+ orderBy: { createdAt: "desc" },
32
+ limit: 50
33
+ },
34
+ { tenantId: scope.tenantId, organizationId: scope.organizationId }
35
+ );
27
36
  return NextResponse.json({
28
37
  items: logs.map((log) => ({
29
38
  id: log.id,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/sso/api/scim/logs/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { ScimProvisioningLog } from '../../../data/entities'\nimport { resolveSsoAdminContext } from '../../admin-context'\nimport { handleSsoAdminApiError } from '../../error-handler'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['sso.config.view'] },\n}\n\nexport async function GET(req: Request) {\n try {\n const { scope } = await resolveSsoAdminContext(req)\n\n const url = new URL(req.url)\n const ssoConfigId = url.searchParams.get('ssoConfigId')\n if (!ssoConfigId) {\n return NextResponse.json({ error: 'ssoConfigId is required' }, { status: 400 })\n }\n\n const where: Record<string, unknown> = { ssoConfigId }\n if (!scope.isSuperAdmin && scope.organizationId) {\n where.organizationId = scope.organizationId\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const logs = await em.find(ScimProvisioningLog, where, {\n orderBy: { createdAt: 'desc' },\n limit: 50,\n })\n\n return NextResponse.json({\n items: logs.map((log) => ({\n id: log.id,\n operation: log.operation,\n resourceType: log.resourceType,\n resourceId: log.resourceId,\n scimExternalId: log.scimExternalId,\n responseStatus: log.responseStatus,\n errorMessage: log.errorMessage,\n createdAt: log.createdAt.toISOString(),\n })),\n })\n } catch (err) {\n return handleSsoAdminApiError(err, 'SCIM Logs API')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'SCIM',\n summary: 'SCIM Provisioning Logs',\n methods: {\n GET: {\n summary: 'List recent provisioning log entries',\n description: 'Returns the last 50 SCIM provisioning log entries for a given SSO config.',\n tags: ['SSO', 'SCIM'],\n responses: [{ status: 200, description: 'List of provisioning log entries' }],\n errors: [\n { status: 400, description: 'Missing ssoConfigId' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Forbidden \u2014 requires sso.scim.manage' },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;AAEvC,SAAS,2BAA2B;AACpC,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AAEhC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,iBAAiB,EAAE;AACjE;AAEA,eAAsB,IAAI,KAAc;AACtC,MAAI;AACF,UAAM,EAAE,MAAM,IAAI,MAAM,uBAAuB,GAAG;AAElD,UAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,UAAM,cAAc,IAAI,aAAa,IAAI,aAAa;AACtD,QAAI,CAAC,aAAa;AAChB,aAAO,aAAa,KAAK,EAAE,OAAO,0BAA0B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAChF;AAEA,UAAM,QAAiC,EAAE,YAAY;AACrD,QAAI,CAAC,MAAM,gBAAgB,MAAM,gBAAgB;AAC/C,YAAM,iBAAiB,MAAM;AAAA,IAC/B;AAEA,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAEhD,UAAM,OAAO,MAAM,GAAG,KAAK,qBAAqB,OAAO;AAAA,MACrD,SAAS,EAAE,WAAW,OAAO;AAAA,MAC7B,OAAO;AAAA,IACT,CAAC;AAED,WAAO,aAAa,KAAK;AAAA,MACvB,OAAO,KAAK,IAAI,CAAC,SAAS;AAAA,QACxB,IAAI,IAAI;AAAA,QACR,WAAW,IAAI;AAAA,QACf,cAAc,IAAI;AAAA,QAClB,YAAY,IAAI;AAAA,QAChB,gBAAgB,IAAI;AAAA,QACpB,gBAAgB,IAAI;AAAA,QACpB,cAAc,IAAI;AAAA,QAClB,WAAW,IAAI,UAAU,YAAY;AAAA,MACvC,EAAE;AAAA,IACJ,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,uBAAuB,KAAK,eAAe;AAAA,EACpD;AACF;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM,CAAC,OAAO,MAAM;AAAA,MACpB,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mCAAmC,CAAC;AAAA,MAC5E,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,sBAAsB;AAAA,QAClD,EAAE,QAAQ,KAAK,aAAa,eAAe;AAAA,QAC3C,EAAE,QAAQ,KAAK,aAAa,4CAAuC;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'\nimport { ScimProvisioningLog } from '../../../data/entities'\nimport { SsoConfigError } from '../../../services/ssoConfigService'\nimport { resolveSsoAdminContext } from '../../admin-context'\nimport { handleSsoAdminApiError } from '../../error-handler'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['sso.config.view'] },\n}\n\nexport async function GET(req: Request) {\n try {\n const { scope } = await resolveSsoAdminContext(req)\n\n const url = new URL(req.url)\n const ssoConfigId = url.searchParams.get('ssoConfigId')\n if (!ssoConfigId) {\n return NextResponse.json({ error: 'ssoConfigId is required' }, { status: 400 })\n }\n\n const where: FilterQuery<ScimProvisioningLog> = { ssoConfigId }\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)\n where.organizationId = scope.organizationId\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<EntityManager>('em')\n\n const logs = await findWithDecryption(\n em,\n ScimProvisioningLog,\n where,\n {\n orderBy: { createdAt: 'desc' },\n limit: 50,\n },\n { tenantId: scope.tenantId, organizationId: scope.organizationId },\n )\n\n return NextResponse.json({\n items: logs.map((log) => ({\n id: log.id,\n operation: log.operation,\n resourceType: log.resourceType,\n resourceId: log.resourceId,\n scimExternalId: log.scimExternalId,\n responseStatus: log.responseStatus,\n errorMessage: log.errorMessage,\n createdAt: log.createdAt.toISOString(),\n })),\n })\n } catch (err) {\n return handleSsoAdminApiError(err, 'SCIM Logs API')\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'SCIM',\n summary: 'SCIM Provisioning Logs',\n methods: {\n GET: {\n summary: 'List recent provisioning log entries',\n description: 'Returns the last 50 SCIM provisioning log entries for a given SSO config.',\n tags: ['SSO', 'SCIM'],\n responses: [{ status: 200, description: 'List of provisioning log entries' }],\n errors: [\n { status: 400, description: 'Missing ssoConfigId' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Forbidden \u2014 requires sso.scim.manage' },\n ],\n },\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AAEnC,SAAS,2BAA2B;AACpC,SAAS,sBAAsB;AAC/B,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AAEhC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,iBAAiB,EAAE;AACjE;AAEA,eAAsB,IAAI,KAAc;AACtC,MAAI;AACF,UAAM,EAAE,MAAM,IAAI,MAAM,uBAAuB,GAAG;AAElD,UAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,UAAM,cAAc,IAAI,aAAa,IAAI,aAAa;AACtD,QAAI,CAAC,aAAa;AAChB,aAAO,aAAa,KAAK,EAAE,OAAO,0BAA0B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAChF;AAEA,UAAM,QAA0C,EAAE,YAAY;AAC9D,QAAI,CAAC,MAAM,cAAc;AACvB,UAAI,CAAC,MAAM,eAAgB,OAAM,IAAI,eAAe,oCAAoC,GAAG;AAC3F,YAAM,iBAAiB,MAAM;AAAA,IAC/B;AAEA,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,KAAK,UAAU,QAAuB,IAAI;AAEhD,UAAM,OAAO,MAAM;AAAA,MACjB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE,SAAS,EAAE,WAAW,OAAO;AAAA,QAC7B,OAAO;AAAA,MACT;AAAA,MACA,EAAE,UAAU,MAAM,UAAU,gBAAgB,MAAM,eAAe;AAAA,IACnE;AAEA,WAAO,aAAa,KAAK;AAAA,MACvB,OAAO,KAAK,IAAI,CAAC,SAAS;AAAA,QACxB,IAAI,IAAI;AAAA,QACR,WAAW,IAAI;AAAA,QACf,cAAc,IAAI;AAAA,QAClB,YAAY,IAAI;AAAA,QAChB,gBAAgB,IAAI;AAAA,QACpB,gBAAgB,IAAI;AAAA,QACpB,cAAc,IAAI;AAAA,QAClB,WAAW,IAAI,UAAU,YAAY;AAAA,MACvC,EAAE;AAAA,IACJ,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,uBAAuB,KAAK,eAAe;AAAA,EACpD;AACF;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM,CAAC,OAAO,MAAM;AAAA,MACpB,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mCAAmC,CAAC;AAAA,MAC5E,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,sBAAsB;AAAA,QAClD,EAAE,QAAQ,KAAK,aAAa,eAAe;AAAA,QAC3C,EAAE,QAAQ,KAAK,aAAa,4CAAuC;AAAA,MACrE;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -8,9 +8,9 @@ class HrdService {
8
8
  const domain = email.split("@")[1]?.toLowerCase();
9
9
  if (!domain) return null;
10
10
  const db = this.em.getKysely();
11
- const row = await db.selectFrom("sso_configs").selectAll().where(sql`allowed_domains @> ${JSON.stringify([domain])}::jsonb`).where("is_active", "=", true).where("deleted_at", "is", null).executeTakeFirst();
12
- if (!row) return null;
13
- return this.em.map(SsoConfig, row);
11
+ const rows = await db.selectFrom("sso_configs").selectAll().where(sql`allowed_domains @> ${JSON.stringify([domain])}::jsonb`).where("is_active", "=", true).where("deleted_at", "is", null).limit(2).execute();
12
+ if (rows.length !== 1) return null;
13
+ return this.em.map(SsoConfig, rows[0]);
14
14
  }
15
15
  }
16
16
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/sso/services/hrdService.ts"],
4
- "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { SsoConfig } from '../data/entities'\n\nexport class HrdService {\n constructor(private em: EntityManager) {}\n\n async findActiveConfigByEmailDomain(email: string): Promise<SsoConfig | null> {\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return null\n\n const db = (this.em as any).getKysely() as Kysely<any>\n const row = await db\n .selectFrom('sso_configs' as any)\n .selectAll()\n .where(sql<boolean>`allowed_domains @> ${JSON.stringify([domain])}::jsonb`)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null as any)\n .executeTakeFirst()\n\n if (!row) return null\n\n return this.em.map(SsoConfig, row as Record<string, unknown>)\n }\n}\n"],
5
- "mappings": "AACA,SAAsB,WAAW;AACjC,SAAS,iBAAiB;AAEnB,MAAM,WAAW;AAAA,EACtB,YAAoB,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAExC,MAAM,8BAA8B,OAA0C;AAC5E,UAAM,SAAS,MAAM,MAAM,GAAG,EAAE,CAAC,GAAG,YAAY;AAChD,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,KAAM,KAAK,GAAW,UAAU;AACtC,UAAM,MAAM,MAAM,GACf,WAAW,aAAoB,EAC/B,UAAU,EACV,MAAM,yBAAkC,KAAK,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EACzE,MAAM,aAAoB,KAAK,IAAI,EACnC,MAAM,cAAqB,MAAM,IAAW,EAC5C,iBAAiB;AAEpB,QAAI,CAAC,IAAK,QAAO;AAEjB,WAAO,KAAK,GAAG,IAAI,WAAW,GAA8B;AAAA,EAC9D;AACF;",
4
+ "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { SsoConfig } from '../data/entities'\n\nexport class HrdService {\n constructor(private em: EntityManager) {}\n\n async findActiveConfigByEmailDomain(email: string): Promise<SsoConfig | null> {\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return null\n\n const db = (this.em as any).getKysely() as Kysely<any>\n const rows = await db\n .selectFrom('sso_configs' as any)\n .selectAll()\n .where(sql<boolean>`allowed_domains @> ${JSON.stringify([domain])}::jsonb`)\n .where('is_active' as any, '=', true)\n .where('deleted_at' as any, 'is', null as any)\n .limit(2)\n .execute()\n\n if (rows.length !== 1) return null\n\n return this.em.map(SsoConfig, rows[0] as Record<string, unknown>)\n }\n}\n"],
5
+ "mappings": "AACA,SAAsB,WAAW;AACjC,SAAS,iBAAiB;AAEnB,MAAM,WAAW;AAAA,EACtB,YAAoB,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAExC,MAAM,8BAA8B,OAA0C;AAC5E,UAAM,SAAS,MAAM,MAAM,GAAG,EAAE,CAAC,GAAG,YAAY;AAChD,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,KAAM,KAAK,GAAW,UAAU;AACtC,UAAM,OAAO,MAAM,GAChB,WAAW,aAAoB,EAC/B,UAAU,EACV,MAAM,yBAAkC,KAAK,UAAU,CAAC,MAAM,CAAC,CAAC,SAAS,EACzE,MAAM,aAAoB,KAAK,IAAI,EACnC,MAAM,cAAqB,MAAM,IAAW,EAC5C,MAAM,CAAC,EACP,QAAQ;AAEX,QAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,WAAO,KAAK,GAAG,IAAI,WAAW,KAAK,CAAC,CAA4B;AAAA,EAClE;AACF;",
6
6
  "names": []
7
7
  }
@@ -9,7 +9,8 @@ class ScimTokenService {
9
9
  }
10
10
  async generateToken(ssoConfigId, name, scope) {
11
11
  const where = { id: ssoConfigId, deletedAt: null };
12
- if (!scope.isSuperAdmin && scope.organizationId) {
12
+ if (!scope.isSuperAdmin) {
13
+ if (!scope.organizationId) throw new ScimTokenError("Organization context is required", 403);
13
14
  where.organizationId = scope.organizationId;
14
15
  }
15
16
  const config = await this.em.findOne(SsoConfig, where);
@@ -27,8 +28,8 @@ class ScimTokenService {
27
28
  tokenPrefix,
28
29
  isActive: true,
29
30
  createdBy: null,
30
- tenantId: scope.tenantId,
31
- organizationId: scope.organizationId
31
+ tenantId: config.tenantId ?? null,
32
+ organizationId: config.organizationId
32
33
  });
33
34
  await this.em.persist(token).flush();
34
35
  return { id: token.id, token: raw, prefix: tokenPrefix, name };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/sso/services/scimTokenService.ts"],
4
- "sourcesContent": ["import { randomBytes } from 'node:crypto'\nimport type { RequiredEntityData } from '@mikro-orm/core'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport { ScimToken, SsoConfig } from '../data/entities'\nimport type { SsoAdminScope } from './ssoConfigService'\n\nconst BCRYPT_COST = 10\nconst TOKEN_PREFIX = 'omscim_'\n\nexport interface ScimTokenPublic {\n id: string\n ssoConfigId: string\n name: string\n tokenPrefix: string\n isActive: boolean\n createdBy: string | null\n createdAt: Date\n}\n\nexport interface ScimTokenCreateResult {\n id: string\n token: string\n prefix: string\n name: string\n}\n\nexport class ScimTokenService {\n constructor(private em: EntityManager) {}\n\n async generateToken(\n ssoConfigId: string,\n name: string,\n scope: SsoAdminScope,\n ): Promise<ScimTokenCreateResult> {\n const where: Record<string, unknown> = { id: ssoConfigId, deletedAt: null }\n if (!scope.isSuperAdmin && scope.organizationId) {\n where.organizationId = scope.organizationId\n }\n const config = await this.em.findOne(SsoConfig, where)\n if (!config) throw new ScimTokenError('SSO configuration not found', 404)\n if (config.jitEnabled) {\n throw new ScimTokenError('Cannot create SCIM tokens while JIT provisioning is enabled. Disable JIT first.', 409)\n }\n\n const raw = TOKEN_PREFIX + randomBytes(32).toString('hex')\n const tokenHash = await hash(raw, BCRYPT_COST)\n const tokenPrefix = raw.slice(0, 12)\n\n const token = this.em.create(ScimToken, {\n ssoConfigId,\n name,\n tokenHash,\n tokenPrefix,\n isActive: true,\n createdBy: null,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId!,\n } as RequiredEntityData<ScimToken>)\n\n await this.em.persist(token).flush()\n\n return { id: token.id, token: raw, prefix: tokenPrefix, name }\n }\n\n async verifyToken(rawToken: string): Promise<{\n ssoConfigId: string\n organizationId: string\n tenantId: string | null\n } | null> {\n const prefix = rawToken.slice(0, 12)\n\n const candidates = await this.em.find(ScimToken, {\n tokenPrefix: prefix,\n isActive: true,\n })\n\n if (candidates.length === 0) {\n await hash(rawToken, BCRYPT_COST)\n return null\n }\n\n for (const candidate of candidates) {\n const isValid = await compare(rawToken, candidate.tokenHash)\n if (isValid) {\n return {\n ssoConfigId: candidate.ssoConfigId,\n organizationId: candidate.organizationId,\n tenantId: candidate.tenantId ?? null,\n }\n }\n }\n\n return null\n }\n\n async revokeToken(tokenId: string, scope: SsoAdminScope): Promise<void> {\n const where: Record<string, unknown> = { id: tokenId }\n if (!scope.isSuperAdmin) where.organizationId = scope.organizationId\n\n const token = await this.em.findOne(ScimToken, where)\n if (!token) throw new ScimTokenError('SCIM token not found', 404)\n\n token.isActive = false\n await this.em.flush()\n }\n\n async listTokens(ssoConfigId: string, scope: SsoAdminScope): Promise<ScimTokenPublic[]> {\n const where: Record<string, unknown> = { ssoConfigId }\n if (!scope.isSuperAdmin) where.organizationId = scope.organizationId\n\n const tokens = await this.em.find(ScimToken, where, {\n orderBy: { createdAt: 'desc' },\n })\n\n return tokens.map((t) => ({\n id: t.id,\n ssoConfigId: t.ssoConfigId,\n name: t.name,\n tokenPrefix: t.tokenPrefix,\n isActive: t.isActive,\n createdBy: t.createdBy ?? null,\n createdAt: t.createdAt,\n }))\n }\n}\n\nexport class ScimTokenError extends Error {\n constructor(\n message: string,\n public readonly statusCode: number,\n ) {\n super(message)\n this.name = 'ScimTokenError'\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomBytes } from 'node:crypto'\nimport type { RequiredEntityData } from '@mikro-orm/core'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport { ScimToken, SsoConfig } from '../data/entities'\nimport type { SsoAdminScope } from './ssoConfigService'\n\nconst BCRYPT_COST = 10\nconst TOKEN_PREFIX = 'omscim_'\n\nexport interface ScimTokenPublic {\n id: string\n ssoConfigId: string\n name: string\n tokenPrefix: string\n isActive: boolean\n createdBy: string | null\n createdAt: Date\n}\n\nexport interface ScimTokenCreateResult {\n id: string\n token: string\n prefix: string\n name: string\n}\n\nexport class ScimTokenService {\n constructor(private em: EntityManager) {}\n\n async generateToken(\n ssoConfigId: string,\n name: string,\n scope: SsoAdminScope,\n ): Promise<ScimTokenCreateResult> {\n const where: Record<string, unknown> = { id: ssoConfigId, deletedAt: null }\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) throw new ScimTokenError('Organization context is required', 403)\n where.organizationId = scope.organizationId\n }\n const config = await this.em.findOne(SsoConfig, where)\n if (!config) throw new ScimTokenError('SSO configuration not found', 404)\n if (config.jitEnabled) {\n throw new ScimTokenError('Cannot create SCIM tokens while JIT provisioning is enabled. Disable JIT first.', 409)\n }\n\n const raw = TOKEN_PREFIX + randomBytes(32).toString('hex')\n const tokenHash = await hash(raw, BCRYPT_COST)\n const tokenPrefix = raw.slice(0, 12)\n\n const token = this.em.create(ScimToken, {\n ssoConfigId,\n name,\n tokenHash,\n tokenPrefix,\n isActive: true,\n createdBy: null,\n tenantId: config.tenantId ?? null,\n organizationId: config.organizationId,\n } as RequiredEntityData<ScimToken>)\n\n await this.em.persist(token).flush()\n\n return { id: token.id, token: raw, prefix: tokenPrefix, name }\n }\n\n async verifyToken(rawToken: string): Promise<{\n ssoConfigId: string\n organizationId: string\n tenantId: string | null\n } | null> {\n const prefix = rawToken.slice(0, 12)\n\n const candidates = await this.em.find(ScimToken, {\n tokenPrefix: prefix,\n isActive: true,\n })\n\n if (candidates.length === 0) {\n await hash(rawToken, BCRYPT_COST)\n return null\n }\n\n for (const candidate of candidates) {\n const isValid = await compare(rawToken, candidate.tokenHash)\n if (isValid) {\n return {\n ssoConfigId: candidate.ssoConfigId,\n organizationId: candidate.organizationId,\n tenantId: candidate.tenantId ?? null,\n }\n }\n }\n\n return null\n }\n\n async revokeToken(tokenId: string, scope: SsoAdminScope): Promise<void> {\n const where: Record<string, unknown> = { id: tokenId }\n if (!scope.isSuperAdmin) where.organizationId = scope.organizationId\n\n const token = await this.em.findOne(ScimToken, where)\n if (!token) throw new ScimTokenError('SCIM token not found', 404)\n\n token.isActive = false\n await this.em.flush()\n }\n\n async listTokens(ssoConfigId: string, scope: SsoAdminScope): Promise<ScimTokenPublic[]> {\n const where: Record<string, unknown> = { ssoConfigId }\n if (!scope.isSuperAdmin) where.organizationId = scope.organizationId\n\n const tokens = await this.em.find(ScimToken, where, {\n orderBy: { createdAt: 'desc' },\n })\n\n return tokens.map((t) => ({\n id: t.id,\n ssoConfigId: t.ssoConfigId,\n name: t.name,\n tokenPrefix: t.tokenPrefix,\n isActive: t.isActive,\n createdBy: t.createdBy ?? null,\n createdAt: t.createdAt,\n }))\n }\n}\n\nexport class ScimTokenError extends Error {\n constructor(\n message: string,\n public readonly statusCode: number,\n ) {\n super(message)\n this.name = 'ScimTokenError'\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,3 +1,4 @@
1
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
1
2
  import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
2
3
  import { SsoConfig, ScimToken } from "../data/entities.js";
3
4
  import { emitSsoEvent } from "../events.js";
@@ -139,33 +140,39 @@ class SsoConfigService {
139
140
  }).catch((e) => console.error("[SSO Event]", e));
140
141
  }
141
142
  async activate(scope, id, active) {
142
- const config = await this.resolveConfig(scope, id);
143
- if (active) {
144
- if (config.allowedDomains.length === 0) {
145
- throw new SsoConfigError("Cannot activate SSO configuration with no allowed domains", 400);
143
+ return this.em.transactional(async (txEm) => {
144
+ const tx = txEm;
145
+ await this.lockActiveDomainMutation(tx, id);
146
+ const config = await this.resolveConfig(scope, id, tx);
147
+ if (active) {
148
+ if (config.allowedDomains.length === 0) {
149
+ throw new SsoConfigError("Cannot activate SSO configuration with no allowed domains", 400);
150
+ }
151
+ await this.lockActiveDomains(tx, config.allowedDomains);
152
+ await this.assertActiveDomainAvailability(tx, config.allowedDomains, config.id);
153
+ const testResult = await this.testConnectionInternal(config);
154
+ if (!testResult.ok) {
155
+ throw new SsoConfigError(`Cannot activate \u2014 discovery failed: ${testResult.error}`, 400);
156
+ }
146
157
  }
147
- const testResult = await this.testConnectionInternal(config);
148
- if (!testResult.ok) {
149
- throw new SsoConfigError(`Cannot activate \u2014 discovery failed: ${testResult.error}`, 400);
158
+ const wasActive = config.isActive;
159
+ config.isActive = active;
160
+ await tx.flush();
161
+ if (active && !wasActive) {
162
+ void emitSsoEvent("sso.config.activated", {
163
+ id: config.id,
164
+ tenantId: config.tenantId,
165
+ organizationId: config.organizationId
166
+ }).catch((e) => console.error("[SSO Event]", e));
167
+ } else if (!active && wasActive) {
168
+ void emitSsoEvent("sso.config.deactivated", {
169
+ id: config.id,
170
+ tenantId: config.tenantId,
171
+ organizationId: config.organizationId
172
+ }).catch((e) => console.error("[SSO Event]", e));
150
173
  }
151
- }
152
- const wasActive = config.isActive;
153
- config.isActive = active;
154
- await this.em.flush();
155
- if (active && !wasActive) {
156
- void emitSsoEvent("sso.config.activated", {
157
- id: config.id,
158
- tenantId: config.tenantId,
159
- organizationId: config.organizationId
160
- }).catch((e) => console.error("[SSO Event]", e));
161
- } else if (!active && wasActive) {
162
- void emitSsoEvent("sso.config.deactivated", {
163
- id: config.id,
164
- tenantId: config.tenantId,
165
- organizationId: config.organizationId
166
- }).catch((e) => console.error("[SSO Event]", e));
167
- }
168
- return this.toPublic(config);
174
+ return this.toPublic(config);
175
+ });
169
176
  }
170
177
  async testConnection(scope, id) {
171
178
  const config = await this.resolveConfig(scope, id);
@@ -175,21 +182,28 @@ class SsoConfigService {
175
182
  const normalized = normalizeDomain(domain);
176
183
  const validation = validateDomain(normalized);
177
184
  if (!validation.valid) throw new SsoConfigError(validation.error, 400);
178
- const config = await this.resolveConfig(scope, id);
179
- if (config.allowedDomains.includes(normalized)) {
185
+ return this.em.transactional(async (txEm) => {
186
+ const tx = txEm;
187
+ await this.lockActiveDomainMutation(tx, id, [normalized]);
188
+ const config = await this.resolveConfig(scope, id, tx);
189
+ if (config.allowedDomains.some((d) => normalizeDomain(d) === normalized)) {
190
+ return this.toPublic(config);
191
+ }
192
+ const limitCheck = checkDomainLimit(config.allowedDomains.length, 1);
193
+ if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error, 400);
194
+ if (config.isActive) {
195
+ await this.assertActiveDomainAvailability(tx, [...config.allowedDomains, normalized], config.id);
196
+ }
197
+ config.allowedDomains = [...config.allowedDomains, normalized];
198
+ await tx.flush();
199
+ void emitSsoEvent("sso.domain.added", {
200
+ id: config.id,
201
+ tenantId: config.tenantId,
202
+ organizationId: config.organizationId,
203
+ domain: normalized
204
+ }).catch((e) => console.error("[SSO Event]", e));
180
205
  return this.toPublic(config);
181
- }
182
- const limitCheck = checkDomainLimit(config.allowedDomains.length, 1);
183
- if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error, 400);
184
- config.allowedDomains = [...config.allowedDomains, normalized];
185
- await this.em.flush();
186
- void emitSsoEvent("sso.domain.added", {
187
- id: config.id,
188
- tenantId: config.tenantId,
189
- organizationId: config.organizationId,
190
- domain: normalized
191
- }).catch((e) => console.error("[SSO Event]", e));
192
- return this.toPublic(config);
206
+ });
193
207
  }
194
208
  async removeDomain(scope, id, domain) {
195
209
  const normalized = normalizeDomain(domain);
@@ -224,13 +238,13 @@ class SsoConfigService {
224
238
  updatedAt: config.updatedAt
225
239
  };
226
240
  }
227
- async resolveConfig(scope, id) {
241
+ async resolveConfig(scope, id, em = this.em) {
228
242
  const where = { id, deletedAt: null };
229
243
  if (!scope.isSuperAdmin) {
230
244
  if (!scope.organizationId) throw new SsoConfigError("Organization context is required", 403);
231
245
  where.organizationId = scope.organizationId;
232
246
  }
233
- const config = await this.em.findOne(SsoConfig, where);
247
+ const config = await em.findOne(SsoConfig, where);
234
248
  if (!config) throw new SsoConfigError("SSO configuration not found", 404);
235
249
  return config;
236
250
  }
@@ -239,6 +253,40 @@ class SsoConfigService {
239
253
  if (!provider) return { ok: false, error: `No provider for protocol: ${config.protocol}` };
240
254
  return provider.validateConfig(config);
241
255
  }
256
+ async lockActiveDomainMutation(em, configId, domains = []) {
257
+ const lockKeys = [
258
+ `sso:sso_config:${configId}`,
259
+ ...uniqueDomains(domains).sort((a, b) => a.localeCompare(b)).map((domain) => `sso:allowed_domain:${domain}`)
260
+ ];
261
+ await this.lockKeys(em, lockKeys);
262
+ }
263
+ async lockActiveDomains(em, domains) {
264
+ const lockKeys = uniqueDomains(domains).sort((a, b) => a.localeCompare(b)).map((domain) => `sso:allowed_domain:${domain}`);
265
+ await this.lockKeys(em, lockKeys);
266
+ }
267
+ async lockKeys(em, lockKeys) {
268
+ for (const lockKey of lockKeys) {
269
+ await em.getConnection().execute("select pg_advisory_xact_lock(hashtext(?::text))", [lockKey]);
270
+ }
271
+ }
272
+ async assertActiveDomainAvailability(em, domains, currentConfigId) {
273
+ const requestedDomains = new Set(uniqueDomains(domains));
274
+ if (requestedDomains.size === 0) return;
275
+ const activeConfigs = await findWithDecryption(
276
+ em,
277
+ SsoConfig,
278
+ { isActive: true, deletedAt: null },
279
+ {},
280
+ { tenantId: null }
281
+ );
282
+ const conflictDomain = activeConfigs.filter((config) => config.id !== currentConfigId).flatMap((config) => config.allowedDomains.map(normalizeDomain)).find((domain) => requestedDomains.has(domain));
283
+ if (conflictDomain) {
284
+ throw new SsoConfigError(
285
+ `SSO domain "${conflictDomain}" is already claimed by another active SSO configuration`,
286
+ 409
287
+ );
288
+ }
289
+ }
242
290
  }
243
291
  class SsoConfigError extends Error {
244
292
  constructor(message, statusCode) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/sso/services/ssoConfigService.ts"],
4
- "sourcesContent": ["import type { FilterQuery, RequiredEntityData } from '@mikro-orm/core'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { SsoConfig, ScimToken } from '../data/entities'\nimport type { SsoConfigAdminCreateInput, SsoConfigAdminUpdateInput, SsoConfigListQuery } from '../data/validators'\nimport { emitSsoEvent } from '../events'\nimport { validateDomain, normalizeDomain, uniqueDomains, checkDomainLimit } from '../lib/domains'\nimport type { SsoProviderRegistry } from '../lib/registry'\n\nexport interface SsoAdminScope {\n isSuperAdmin: boolean\n organizationId: string | null\n tenantId: string | null\n}\n\nexport interface SsoConfigPublic {\n id: string\n name: string | null\n tenantId: string | null\n organizationId: string\n protocol: string\n issuer: string | null\n clientId: string | null\n hasClientSecret: boolean\n allowedDomains: string[]\n jitEnabled: boolean\n autoLinkByEmail: boolean\n isActive: boolean\n ssoRequired: boolean\n appRoleMappings: Record<string, string>\n createdAt: Date\n updatedAt: Date\n}\n\nexport class SsoConfigService {\n constructor(\n private em: EntityManager,\n private tenantEncryptionService: TenantDataEncryptionService,\n private ssoProviderRegistry: SsoProviderRegistry,\n ) {}\n\n async list(scope: SsoAdminScope, query: SsoConfigListQuery): Promise<{\n items: SsoConfigPublic[]\n total: number\n totalPages: number\n }> {\n const where: FilterQuery<SsoConfig> = { deletedAt: null }\n\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) {\n throw new SsoConfigError('Organization context is required', 403)\n }\n where.organizationId = scope.organizationId\n } else {\n if (query.organizationId) where.organizationId = query.organizationId\n if (query.tenantId) where.tenantId = query.tenantId\n }\n\n if (query.search) {\n const pattern = `%${escapeLikePattern(query.search)}%`\n where.$or = [\n { name: { $ilike: pattern } },\n { issuer: { $ilike: pattern } },\n { clientId: { $ilike: pattern } },\n ]\n }\n\n const [configs, total] = await this.em.findAndCount(SsoConfig, where, {\n orderBy: { createdAt: 'desc' },\n limit: query.pageSize,\n offset: (query.page - 1) * query.pageSize,\n })\n\n return {\n items: configs.map((c) => this.toPublic(c)),\n total,\n totalPages: Math.ceil(total / query.pageSize) || 1,\n }\n }\n\n async getById(scope: SsoAdminScope, id: string): Promise<SsoConfigPublic | null> {\n const where: FilterQuery<SsoConfig> = { id, deletedAt: null }\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)\n where.organizationId = scope.organizationId\n }\n\n const config = await this.em.findOne(SsoConfig, where)\n return config ? this.toPublic(config) : null\n }\n\n async create(scope: SsoAdminScope, input: SsoConfigAdminCreateInput): Promise<SsoConfigPublic> {\n const orgId = scope.isSuperAdmin ? input.organizationId : scope.organizationId!\n const tenId = scope.isSuperAdmin ? (input.tenantId ?? null) : scope.tenantId\n\n const existing = await this.em.findOne(SsoConfig, {\n organizationId: orgId,\n deletedAt: null,\n })\n if (existing) {\n throw new SsoConfigError('An SSO configuration already exists for this organization', 409)\n }\n\n const domains = uniqueDomains(input.allowedDomains)\n for (const d of domains) {\n const result = validateDomain(d)\n if (!result.valid) throw new SsoConfigError(`Invalid domain \"${d}\": ${result.error}`, 400)\n }\n\n const encrypted = await this.tenantEncryptionService.encryptEntityPayload(\n 'SsoConfig',\n { clientSecretEnc: input.clientSecret },\n tenId,\n orgId,\n )\n\n const config = this.em.create(SsoConfig, {\n name: input.name,\n tenantId: tenId,\n organizationId: orgId,\n protocol: input.protocol,\n issuer: input.issuer,\n clientId: input.clientId,\n clientSecretEnc: encrypted.clientSecretEnc as string,\n allowedDomains: domains,\n jitEnabled: input.jitEnabled,\n autoLinkByEmail: input.autoLinkByEmail,\n isActive: false,\n ssoRequired: false,\n appRoleMappings: input.appRoleMappings ?? {},\n } as RequiredEntityData<SsoConfig>)\n\n await this.em.persist(config).flush()\n\n void emitSsoEvent('sso.config.created', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n async update(scope: SsoAdminScope, id: string, input: SsoConfigAdminUpdateInput): Promise<SsoConfigPublic> {\n const config = await this.resolveConfig(scope, id)\n\n if (input.name !== undefined) config.name = input.name\n if (input.protocol !== undefined) config.protocol = input.protocol\n if (input.issuer !== undefined) config.issuer = input.issuer\n if (input.clientId !== undefined) config.clientId = input.clientId\n if (input.jitEnabled !== undefined) {\n if (input.jitEnabled) {\n const activeScimCount = await this.em.count(ScimToken, { ssoConfigId: id, isActive: true })\n if (activeScimCount > 0) {\n throw new SsoConfigError('Cannot enable JIT provisioning while SCIM directory sync is active. Revoke all SCIM tokens first.', 409)\n }\n }\n config.jitEnabled = input.jitEnabled\n }\n if (input.autoLinkByEmail !== undefined) config.autoLinkByEmail = input.autoLinkByEmail\n if (input.appRoleMappings !== undefined) config.appRoleMappings = input.appRoleMappings\n\n if (input.clientSecret !== undefined) {\n const encrypted = await this.tenantEncryptionService.encryptEntityPayload(\n 'SsoConfig',\n { clientSecretEnc: input.clientSecret },\n config.tenantId,\n config.organizationId,\n )\n config.clientSecretEnc = encrypted.clientSecretEnc as string\n }\n\n await this.em.flush()\n\n void emitSsoEvent('sso.config.updated', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n async delete(scope: SsoAdminScope, id: string): Promise<void> {\n const config = await this.resolveConfig(scope, id)\n\n if (config.isActive) {\n throw new SsoConfigError('Cannot delete an active SSO configuration \u2014 deactivate it first', 400)\n }\n\n config.deletedAt = new Date()\n await this.em.flush()\n\n void emitSsoEvent('sso.config.deleted', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n }\n\n async activate(scope: SsoAdminScope, id: string, active: boolean): Promise<SsoConfigPublic> {\n const config = await this.resolveConfig(scope, id)\n\n if (active) {\n if (config.allowedDomains.length === 0) {\n throw new SsoConfigError('Cannot activate SSO configuration with no allowed domains', 400)\n }\n\n const testResult = await this.testConnectionInternal(config)\n if (!testResult.ok) {\n throw new SsoConfigError(`Cannot activate \u2014 discovery failed: ${testResult.error}`, 400)\n }\n }\n\n const wasActive = config.isActive\n config.isActive = active\n await this.em.flush()\n\n if (active && !wasActive) {\n void emitSsoEvent('sso.config.activated', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n } else if (!active && wasActive) {\n void emitSsoEvent('sso.config.deactivated', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n }\n\n return this.toPublic(config)\n }\n\n async testConnection(scope: SsoAdminScope, id: string): Promise<{ ok: boolean; error?: string }> {\n const config = await this.resolveConfig(scope, id)\n return this.testConnectionInternal(config)\n }\n\n async addDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {\n const normalized = normalizeDomain(domain)\n const validation = validateDomain(normalized)\n if (!validation.valid) throw new SsoConfigError(validation.error!, 400)\n\n const config = await this.resolveConfig(scope, id)\n\n if (config.allowedDomains.includes(normalized)) {\n return this.toPublic(config)\n }\n\n const limitCheck = checkDomainLimit(config.allowedDomains.length, 1)\n if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error!, 400)\n\n config.allowedDomains = [...config.allowedDomains, normalized]\n await this.em.flush()\n\n void emitSsoEvent('sso.domain.added', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n domain: normalized,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n async removeDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {\n const normalized = normalizeDomain(domain)\n const config = await this.resolveConfig(scope, id)\n\n config.allowedDomains = config.allowedDomains.filter((d) => d !== normalized)\n await this.em.flush()\n\n void emitSsoEvent('sso.domain.removed', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n domain: normalized,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n toPublic(config: SsoConfig): SsoConfigPublic {\n return {\n id: config.id,\n name: config.name ?? null,\n tenantId: config.tenantId ?? null,\n organizationId: config.organizationId,\n protocol: config.protocol,\n issuer: config.issuer ?? null,\n clientId: config.clientId ?? null,\n hasClientSecret: !!config.clientSecretEnc,\n allowedDomains: config.allowedDomains,\n jitEnabled: config.jitEnabled,\n autoLinkByEmail: config.autoLinkByEmail,\n isActive: config.isActive,\n ssoRequired: config.ssoRequired,\n appRoleMappings: config.appRoleMappings ?? {},\n createdAt: config.createdAt,\n updatedAt: config.updatedAt,\n }\n }\n\n private async resolveConfig(scope: SsoAdminScope, id: string): Promise<SsoConfig> {\n const where: FilterQuery<SsoConfig> = { id, deletedAt: null }\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)\n where.organizationId = scope.organizationId\n }\n\n const config = await this.em.findOne(SsoConfig, where)\n if (!config) throw new SsoConfigError('SSO configuration not found', 404)\n\n return config\n }\n\n private async testConnectionInternal(config: SsoConfig): Promise<{ ok: boolean; error?: string }> {\n const provider = this.ssoProviderRegistry.resolve(config.protocol)\n if (!provider) return { ok: false, error: `No provider for protocol: ${config.protocol}` }\n\n return provider.validateConfig(config)\n }\n}\n\nexport class SsoConfigError extends Error {\n constructor(\n message: string,\n public readonly statusCode: number,\n ) {\n super(message)\n this.name = 'SsoConfigError'\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { FilterQuery, RequiredEntityData } from '@mikro-orm/core'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { SsoConfig, ScimToken } from '../data/entities'\nimport type { SsoConfigAdminCreateInput, SsoConfigAdminUpdateInput, SsoConfigListQuery } from '../data/validators'\nimport { emitSsoEvent } from '../events'\nimport { validateDomain, normalizeDomain, uniqueDomains, checkDomainLimit } from '../lib/domains'\nimport type { SsoProviderRegistry } from '../lib/registry'\n\nexport interface SsoAdminScope {\n isSuperAdmin: boolean\n organizationId: string | null\n tenantId: string | null\n}\n\nexport interface SsoConfigPublic {\n id: string\n name: string | null\n tenantId: string | null\n organizationId: string\n protocol: string\n issuer: string | null\n clientId: string | null\n hasClientSecret: boolean\n allowedDomains: string[]\n jitEnabled: boolean\n autoLinkByEmail: boolean\n isActive: boolean\n ssoRequired: boolean\n appRoleMappings: Record<string, string>\n createdAt: Date\n updatedAt: Date\n}\n\nexport class SsoConfigService {\n constructor(\n private em: EntityManager,\n private tenantEncryptionService: TenantDataEncryptionService,\n private ssoProviderRegistry: SsoProviderRegistry,\n ) {}\n\n async list(scope: SsoAdminScope, query: SsoConfigListQuery): Promise<{\n items: SsoConfigPublic[]\n total: number\n totalPages: number\n }> {\n const where: FilterQuery<SsoConfig> = { deletedAt: null }\n\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) {\n throw new SsoConfigError('Organization context is required', 403)\n }\n where.organizationId = scope.organizationId\n } else {\n if (query.organizationId) where.organizationId = query.organizationId\n if (query.tenantId) where.tenantId = query.tenantId\n }\n\n if (query.search) {\n const pattern = `%${escapeLikePattern(query.search)}%`\n where.$or = [\n { name: { $ilike: pattern } },\n { issuer: { $ilike: pattern } },\n { clientId: { $ilike: pattern } },\n ]\n }\n\n const [configs, total] = await this.em.findAndCount(SsoConfig, where, {\n orderBy: { createdAt: 'desc' },\n limit: query.pageSize,\n offset: (query.page - 1) * query.pageSize,\n })\n\n return {\n items: configs.map((c) => this.toPublic(c)),\n total,\n totalPages: Math.ceil(total / query.pageSize) || 1,\n }\n }\n\n async getById(scope: SsoAdminScope, id: string): Promise<SsoConfigPublic | null> {\n const where: FilterQuery<SsoConfig> = { id, deletedAt: null }\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)\n where.organizationId = scope.organizationId\n }\n\n const config = await this.em.findOne(SsoConfig, where)\n return config ? this.toPublic(config) : null\n }\n\n async create(scope: SsoAdminScope, input: SsoConfigAdminCreateInput): Promise<SsoConfigPublic> {\n const orgId = scope.isSuperAdmin ? input.organizationId : scope.organizationId!\n const tenId = scope.isSuperAdmin ? (input.tenantId ?? null) : scope.tenantId\n\n const existing = await this.em.findOne(SsoConfig, {\n organizationId: orgId,\n deletedAt: null,\n })\n if (existing) {\n throw new SsoConfigError('An SSO configuration already exists for this organization', 409)\n }\n\n const domains = uniqueDomains(input.allowedDomains)\n for (const d of domains) {\n const result = validateDomain(d)\n if (!result.valid) throw new SsoConfigError(`Invalid domain \"${d}\": ${result.error}`, 400)\n }\n\n const encrypted = await this.tenantEncryptionService.encryptEntityPayload(\n 'SsoConfig',\n { clientSecretEnc: input.clientSecret },\n tenId,\n orgId,\n )\n\n const config = this.em.create(SsoConfig, {\n name: input.name,\n tenantId: tenId,\n organizationId: orgId,\n protocol: input.protocol,\n issuer: input.issuer,\n clientId: input.clientId,\n clientSecretEnc: encrypted.clientSecretEnc as string,\n allowedDomains: domains,\n jitEnabled: input.jitEnabled,\n autoLinkByEmail: input.autoLinkByEmail,\n isActive: false,\n ssoRequired: false,\n appRoleMappings: input.appRoleMappings ?? {},\n } as RequiredEntityData<SsoConfig>)\n\n await this.em.persist(config).flush()\n\n void emitSsoEvent('sso.config.created', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n async update(scope: SsoAdminScope, id: string, input: SsoConfigAdminUpdateInput): Promise<SsoConfigPublic> {\n const config = await this.resolveConfig(scope, id)\n\n if (input.name !== undefined) config.name = input.name\n if (input.protocol !== undefined) config.protocol = input.protocol\n if (input.issuer !== undefined) config.issuer = input.issuer\n if (input.clientId !== undefined) config.clientId = input.clientId\n if (input.jitEnabled !== undefined) {\n if (input.jitEnabled) {\n const activeScimCount = await this.em.count(ScimToken, { ssoConfigId: id, isActive: true })\n if (activeScimCount > 0) {\n throw new SsoConfigError('Cannot enable JIT provisioning while SCIM directory sync is active. Revoke all SCIM tokens first.', 409)\n }\n }\n config.jitEnabled = input.jitEnabled\n }\n if (input.autoLinkByEmail !== undefined) config.autoLinkByEmail = input.autoLinkByEmail\n if (input.appRoleMappings !== undefined) config.appRoleMappings = input.appRoleMappings\n\n if (input.clientSecret !== undefined) {\n const encrypted = await this.tenantEncryptionService.encryptEntityPayload(\n 'SsoConfig',\n { clientSecretEnc: input.clientSecret },\n config.tenantId,\n config.organizationId,\n )\n config.clientSecretEnc = encrypted.clientSecretEnc as string\n }\n\n await this.em.flush()\n\n void emitSsoEvent('sso.config.updated', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n async delete(scope: SsoAdminScope, id: string): Promise<void> {\n const config = await this.resolveConfig(scope, id)\n\n if (config.isActive) {\n throw new SsoConfigError('Cannot delete an active SSO configuration \u2014 deactivate it first', 400)\n }\n\n config.deletedAt = new Date()\n await this.em.flush()\n\n void emitSsoEvent('sso.config.deleted', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n }\n\n async activate(scope: SsoAdminScope, id: string, active: boolean): Promise<SsoConfigPublic> {\n return this.em.transactional(async (txEm) => {\n const tx = txEm as EntityManager\n await this.lockActiveDomainMutation(tx, id)\n const config = await this.resolveConfig(scope, id, tx)\n\n if (active) {\n if (config.allowedDomains.length === 0) {\n throw new SsoConfigError('Cannot activate SSO configuration with no allowed domains', 400)\n }\n\n await this.lockActiveDomains(tx, config.allowedDomains)\n await this.assertActiveDomainAvailability(tx, config.allowedDomains, config.id)\n\n const testResult = await this.testConnectionInternal(config)\n if (!testResult.ok) {\n throw new SsoConfigError(`Cannot activate \u2014 discovery failed: ${testResult.error}`, 400)\n }\n }\n\n const wasActive = config.isActive\n config.isActive = active\n await tx.flush()\n\n if (active && !wasActive) {\n void emitSsoEvent('sso.config.activated', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n } else if (!active && wasActive) {\n void emitSsoEvent('sso.config.deactivated', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n }\n\n return this.toPublic(config)\n })\n }\n\n async testConnection(scope: SsoAdminScope, id: string): Promise<{ ok: boolean; error?: string }> {\n const config = await this.resolveConfig(scope, id)\n return this.testConnectionInternal(config)\n }\n\n async addDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {\n const normalized = normalizeDomain(domain)\n const validation = validateDomain(normalized)\n if (!validation.valid) throw new SsoConfigError(validation.error!, 400)\n\n return this.em.transactional(async (txEm) => {\n const tx = txEm as EntityManager\n await this.lockActiveDomainMutation(tx, id, [normalized])\n const config = await this.resolveConfig(scope, id, tx)\n\n if (config.allowedDomains.some((d) => normalizeDomain(d) === normalized)) {\n return this.toPublic(config)\n }\n\n const limitCheck = checkDomainLimit(config.allowedDomains.length, 1)\n if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error!, 400)\n\n if (config.isActive) {\n await this.assertActiveDomainAvailability(tx, [...config.allowedDomains, normalized], config.id)\n }\n\n config.allowedDomains = [...config.allowedDomains, normalized]\n await tx.flush()\n\n void emitSsoEvent('sso.domain.added', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n domain: normalized,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n })\n }\n\n async removeDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {\n const normalized = normalizeDomain(domain)\n const config = await this.resolveConfig(scope, id)\n\n config.allowedDomains = config.allowedDomains.filter((d) => d !== normalized)\n await this.em.flush()\n\n void emitSsoEvent('sso.domain.removed', {\n id: config.id,\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n domain: normalized,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return this.toPublic(config)\n }\n\n toPublic(config: SsoConfig): SsoConfigPublic {\n return {\n id: config.id,\n name: config.name ?? null,\n tenantId: config.tenantId ?? null,\n organizationId: config.organizationId,\n protocol: config.protocol,\n issuer: config.issuer ?? null,\n clientId: config.clientId ?? null,\n hasClientSecret: !!config.clientSecretEnc,\n allowedDomains: config.allowedDomains,\n jitEnabled: config.jitEnabled,\n autoLinkByEmail: config.autoLinkByEmail,\n isActive: config.isActive,\n ssoRequired: config.ssoRequired,\n appRoleMappings: config.appRoleMappings ?? {},\n createdAt: config.createdAt,\n updatedAt: config.updatedAt,\n }\n }\n\n private async resolveConfig(scope: SsoAdminScope, id: string, em: EntityManager = this.em): Promise<SsoConfig> {\n const where: FilterQuery<SsoConfig> = { id, deletedAt: null }\n if (!scope.isSuperAdmin) {\n if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)\n where.organizationId = scope.organizationId\n }\n\n const config = await em.findOne(SsoConfig, where)\n if (!config) throw new SsoConfigError('SSO configuration not found', 404)\n\n return config\n }\n\n private async testConnectionInternal(config: SsoConfig): Promise<{ ok: boolean; error?: string }> {\n const provider = this.ssoProviderRegistry.resolve(config.protocol)\n if (!provider) return { ok: false, error: `No provider for protocol: ${config.protocol}` }\n\n return provider.validateConfig(config)\n }\n\n private async lockActiveDomainMutation(em: EntityManager, configId: string, domains: string[] = []): Promise<void> {\n const lockKeys = [\n `sso:sso_config:${configId}`,\n ...uniqueDomains(domains)\n .sort((a, b) => a.localeCompare(b))\n .map((domain) => `sso:allowed_domain:${domain}`),\n ]\n\n await this.lockKeys(em, lockKeys)\n }\n\n private async lockActiveDomains(em: EntityManager, domains: string[]): Promise<void> {\n const lockKeys = uniqueDomains(domains)\n .sort((a, b) => a.localeCompare(b))\n .map((domain) => `sso:allowed_domain:${domain}`)\n await this.lockKeys(em, lockKeys)\n }\n\n private async lockKeys(em: EntityManager, lockKeys: string[]): Promise<void> {\n for (const lockKey of lockKeys) {\n await em.getConnection().execute('select pg_advisory_xact_lock(hashtext(?::text))', [lockKey])\n }\n }\n\n private async assertActiveDomainAvailability(\n em: EntityManager,\n domains: string[],\n currentConfigId: string,\n ): Promise<void> {\n const requestedDomains = new Set(uniqueDomains(domains))\n if (requestedDomains.size === 0) return\n\n const activeConfigs = await findWithDecryption(\n em,\n SsoConfig,\n { isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n const conflictDomain = activeConfigs\n .filter((config) => config.id !== currentConfigId)\n .flatMap((config) => config.allowedDomains.map(normalizeDomain))\n .find((domain) => requestedDomains.has(domain))\n\n if (conflictDomain) {\n throw new SsoConfigError(\n `SSO domain \"${conflictDomain}\" is already claimed by another active SSO configuration`,\n 409,\n )\n }\n }\n}\n\nexport class SsoConfigError extends Error {\n constructor(\n message: string,\n public readonly statusCode: number,\n ) {\n super(message)\n this.name = 'SsoConfigError'\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -23,7 +23,8 @@ class SsoService {
23
23
  {},
24
24
  { tenantId: null }
25
25
  );
26
- return configs.find((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain)) ?? null;
26
+ const matches = configs.filter((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain));
27
+ return matches.length === 1 ? matches[0] : null;
27
28
  }
28
29
  async initiateLogin(configId, returnUrl, redirectUri) {
29
30
  const config = await findOneWithDecryption(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/sso/services/ssoService.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport type { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { SsoConfig } from '../data/entities'\nimport type { SsoProviderRegistry } from '../lib/registry'\nimport type { AccountLinkingService } from './accountLinkingService'\nimport { encryptStateCookie, decryptStateCookie, createFlowState } from '../lib/state-cookie'\nimport { emitSsoEvent } from '../events'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\n\nexport class SsoService {\n constructor(\n private em: EntityManager,\n private ssoProviderRegistry: SsoProviderRegistry,\n private accountLinkingService: AccountLinkingService,\n private tenantEncryptionService: TenantDataEncryptionService,\n private authService: AuthService,\n private rbacService: RbacService,\n ) {}\n\n async findConfigByEmail(email: string): Promise<SsoConfig | null> {\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return null\n\n const configs = await findWithDecryption(\n this.em,\n SsoConfig,\n { isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n return configs.find((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain)) ?? null\n }\n\n async initiateLogin(\n configId: string,\n returnUrl: string,\n redirectUri: string,\n ): Promise<{ redirectUrl: string; stateCookie: string }> {\n const config = await findOneWithDecryption(\n this.em,\n SsoConfig,\n { id: configId, isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n if (!config) throw new Error('SSO configuration not found or inactive')\n\n const provider = this.ssoProviderRegistry.resolve(config.protocol)\n if (!provider) throw new Error(`No provider registered for protocol: ${config.protocol}`)\n\n const clientSecret = await this.decryptClientSecret(config)\n\n const { state } = createFlowState({ configId, returnUrl })\n\n void emitSsoEvent('sso.login.initiated', {\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n const authUrl = await provider.buildAuthUrl(config, {\n state: state.state,\n nonce: state.nonce,\n redirectUri,\n codeVerifier: state.codeVerifier,\n clientSecret,\n })\n\n const stateCookie = encryptStateCookie(state)\n return { redirectUrl: authUrl, stateCookie }\n }\n\n async handleOidcCallback(\n callbackParams: Record<string, string>,\n stateCookie: string,\n redirectUri: string,\n ): Promise<{\n token: string\n sessionToken: string\n sessionExpiresAt: Date\n redirectUrl: string\n tenantId: string | null\n organizationId: string\n }> {\n const flowState = decryptStateCookie(stateCookie)\n if (!flowState) throw new Error('Invalid or expired SSO state')\n\n const receivedState = Buffer.from(callbackParams.state || '')\n const expectedState = Buffer.from(flowState.state)\n if (receivedState.length !== expectedState.length || !crypto.timingSafeEqual(receivedState, expectedState)) {\n throw new Error('State mismatch \u2014 possible CSRF attack')\n }\n\n const config = await findOneWithDecryption(\n this.em,\n SsoConfig,\n { id: flowState.configId, isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n if (!config) throw new Error('SSO configuration no longer active')\n\n const provider = this.ssoProviderRegistry.resolve(config.protocol)\n if (!provider) throw new Error(`No provider for protocol: ${config.protocol}`)\n\n const clientSecret = await this.decryptClientSecret(config)\n\n const idpPayload = await provider.handleCallback(config, {\n callbackParams,\n redirectUri,\n expectedState: flowState.state,\n expectedNonce: flowState.nonce,\n codeVerifier: flowState.codeVerifier,\n clientSecret,\n })\n\n const tenantId = config.tenantId ?? ''\n const { user } = await this.accountLinkingService.resolveUser(config, idpPayload, tenantId)\n\n await this.rbacService.invalidateUserCache(String(user.id))\n\n const roles = await this.authService.getUserRoles(user, tenantId || null)\n\n await this.authService.updateLastLoginAt(user)\n\n const days = Number(process.env.REMEMBER_ME_DAYS || '30')\n const sessionExpiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n const { session, token: sessionRefreshToken } = await this.authService.createSession(user, sessionExpiresAt)\n\n const token = signJwt({\n sub: String(user.id),\n sid: String(session.id),\n tenantId: tenantId || null,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles,\n })\n\n void emitSsoEvent('sso.login.completed', {\n id: String(user.id),\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return {\n token,\n sessionToken: sessionRefreshToken,\n sessionExpiresAt,\n redirectUrl: flowState.returnUrl || '/backend',\n tenantId: config.tenantId ?? null,\n organizationId: config.organizationId,\n }\n }\n\n private async decryptClientSecret(config: SsoConfig): Promise<string | undefined> {\n if (!config.clientSecretEnc) return undefined\n\n const decrypted = await this.tenantEncryptionService.decryptEntityPayload(\n config.id,\n { clientSecretEnc: config.clientSecretEnc },\n config.tenantId,\n config.organizationId,\n )\n return (decrypted.clientSecretEnc as string) ?? undefined\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { signJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport type { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { SsoConfig } from '../data/entities'\nimport type { SsoProviderRegistry } from '../lib/registry'\nimport type { AccountLinkingService } from './accountLinkingService'\nimport { encryptStateCookie, decryptStateCookie, createFlowState } from '../lib/state-cookie'\nimport { emitSsoEvent } from '../events'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\n\nexport class SsoService {\n constructor(\n private em: EntityManager,\n private ssoProviderRegistry: SsoProviderRegistry,\n private accountLinkingService: AccountLinkingService,\n private tenantEncryptionService: TenantDataEncryptionService,\n private authService: AuthService,\n private rbacService: RbacService,\n ) {}\n\n async findConfigByEmail(email: string): Promise<SsoConfig | null> {\n const domain = email.split('@')[1]?.toLowerCase()\n if (!domain) return null\n\n const configs = await findWithDecryption(\n this.em,\n SsoConfig,\n { isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n const matches = configs.filter((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain))\n return matches.length === 1 ? matches[0]! : null\n }\n\n async initiateLogin(\n configId: string,\n returnUrl: string,\n redirectUri: string,\n ): Promise<{ redirectUrl: string; stateCookie: string }> {\n const config = await findOneWithDecryption(\n this.em,\n SsoConfig,\n { id: configId, isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n if (!config) throw new Error('SSO configuration not found or inactive')\n\n const provider = this.ssoProviderRegistry.resolve(config.protocol)\n if (!provider) throw new Error(`No provider registered for protocol: ${config.protocol}`)\n\n const clientSecret = await this.decryptClientSecret(config)\n\n const { state } = createFlowState({ configId, returnUrl })\n\n void emitSsoEvent('sso.login.initiated', {\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n const authUrl = await provider.buildAuthUrl(config, {\n state: state.state,\n nonce: state.nonce,\n redirectUri,\n codeVerifier: state.codeVerifier,\n clientSecret,\n })\n\n const stateCookie = encryptStateCookie(state)\n return { redirectUrl: authUrl, stateCookie }\n }\n\n async handleOidcCallback(\n callbackParams: Record<string, string>,\n stateCookie: string,\n redirectUri: string,\n ): Promise<{\n token: string\n sessionToken: string\n sessionExpiresAt: Date\n redirectUrl: string\n tenantId: string | null\n organizationId: string\n }> {\n const flowState = decryptStateCookie(stateCookie)\n if (!flowState) throw new Error('Invalid or expired SSO state')\n\n const receivedState = Buffer.from(callbackParams.state || '')\n const expectedState = Buffer.from(flowState.state)\n if (receivedState.length !== expectedState.length || !crypto.timingSafeEqual(receivedState, expectedState)) {\n throw new Error('State mismatch \u2014 possible CSRF attack')\n }\n\n const config = await findOneWithDecryption(\n this.em,\n SsoConfig,\n { id: flowState.configId, isActive: true, deletedAt: null },\n {},\n { tenantId: null },\n )\n if (!config) throw new Error('SSO configuration no longer active')\n\n const provider = this.ssoProviderRegistry.resolve(config.protocol)\n if (!provider) throw new Error(`No provider for protocol: ${config.protocol}`)\n\n const clientSecret = await this.decryptClientSecret(config)\n\n const idpPayload = await provider.handleCallback(config, {\n callbackParams,\n redirectUri,\n expectedState: flowState.state,\n expectedNonce: flowState.nonce,\n codeVerifier: flowState.codeVerifier,\n clientSecret,\n })\n\n const tenantId = config.tenantId ?? ''\n const { user } = await this.accountLinkingService.resolveUser(config, idpPayload, tenantId)\n\n await this.rbacService.invalidateUserCache(String(user.id))\n\n const roles = await this.authService.getUserRoles(user, tenantId || null)\n\n await this.authService.updateLastLoginAt(user)\n\n const days = Number(process.env.REMEMBER_ME_DAYS || '30')\n const sessionExpiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)\n const { session, token: sessionRefreshToken } = await this.authService.createSession(user, sessionExpiresAt)\n\n const token = signJwt({\n sub: String(user.id),\n sid: String(session.id),\n tenantId: tenantId || null,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles,\n })\n\n void emitSsoEvent('sso.login.completed', {\n id: String(user.id),\n tenantId: config.tenantId,\n organizationId: config.organizationId,\n }).catch((e) => console.error('[SSO Event]', e))\n\n return {\n token,\n sessionToken: sessionRefreshToken,\n sessionExpiresAt,\n redirectUrl: flowState.returnUrl || '/backend',\n tenantId: config.tenantId ?? null,\n organizationId: config.organizationId,\n }\n }\n\n private async decryptClientSecret(config: SsoConfig): Promise<string | undefined> {\n if (!config.clientSecretEnc) return undefined\n\n const decrypted = await this.tenantEncryptionService.decryptEntityPayload(\n config.id,\n { clientSecretEnc: config.clientSecretEnc },\n config.tenantId,\n config.organizationId,\n )\n return (decrypted.clientSecretEnc as string) ?? undefined\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/enterprise",
3
- "version": "0.6.6-develop.6431.1.g1037269810",
3
+ "version": "0.6.6-develop.6450.1.b493fb430c",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@open-mercato/core": "0.6.6-develop.6431.1.g1037269810",
69
- "@open-mercato/ui": "0.6.6-develop.6431.1.g1037269810",
68
+ "@open-mercato/core": "0.6.6-develop.6450.1.b493fb430c",
69
+ "@open-mercato/ui": "0.6.6-develop.6450.1.b493fb430c",
70
70
  "@simplewebauthn/browser": "^13.3.0",
71
71
  "@simplewebauthn/server": "^13.3.2",
72
72
  "@simplewebauthn/types": "^12.0.0",
@@ -76,12 +76,12 @@
76
76
  "qrcode": "^1.5.4"
77
77
  },
78
78
  "peerDependencies": {
79
- "@open-mercato/shared": "0.6.6-develop.6431.1.g1037269810",
79
+ "@open-mercato/shared": "0.6.6-develop.6450.1.b493fb430c",
80
80
  "react": "^19.0.0",
81
81
  "react-dom": "^19.0.0"
82
82
  },
83
83
  "devDependencies": {
84
- "@open-mercato/shared": "0.6.6-develop.6431.1.g1037269810",
84
+ "@open-mercato/shared": "0.6.6-develop.6450.1.b493fb430c",
85
85
  "@types/jest": "^30.0.0",
86
86
  "@types/react": "^19.2.17",
87
87
  "@types/react-dom": "^19.2.3",
@@ -1,8 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
- import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
5
6
  import { ScimProvisioningLog } from '../../../data/entities'
7
+ import { SsoConfigError } from '../../../services/ssoConfigService'
6
8
  import { resolveSsoAdminContext } from '../../admin-context'
7
9
  import { handleSsoAdminApiError } from '../../error-handler'
8
10
 
@@ -20,18 +22,25 @@ export async function GET(req: Request) {
20
22
  return NextResponse.json({ error: 'ssoConfigId is required' }, { status: 400 })
21
23
  }
22
24
 
23
- const where: Record<string, unknown> = { ssoConfigId }
24
- if (!scope.isSuperAdmin && scope.organizationId) {
25
+ const where: FilterQuery<ScimProvisioningLog> = { ssoConfigId }
26
+ if (!scope.isSuperAdmin) {
27
+ if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)
25
28
  where.organizationId = scope.organizationId
26
29
  }
27
30
 
28
31
  const container = await createRequestContainer()
29
32
  const em = container.resolve<EntityManager>('em')
30
33
 
31
- const logs = await em.find(ScimProvisioningLog, where, {
32
- orderBy: { createdAt: 'desc' },
33
- limit: 50,
34
- })
34
+ const logs = await findWithDecryption(
35
+ em,
36
+ ScimProvisioningLog,
37
+ where,
38
+ {
39
+ orderBy: { createdAt: 'desc' },
40
+ limit: 50,
41
+ },
42
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
43
+ )
35
44
 
36
45
  return NextResponse.json({
37
46
  items: logs.map((log) => ({
@@ -10,16 +10,17 @@ export class HrdService {
10
10
  if (!domain) return null
11
11
 
12
12
  const db = (this.em as any).getKysely() as Kysely<any>
13
- const row = await db
13
+ const rows = await db
14
14
  .selectFrom('sso_configs' as any)
15
15
  .selectAll()
16
16
  .where(sql<boolean>`allowed_domains @> ${JSON.stringify([domain])}::jsonb`)
17
17
  .where('is_active' as any, '=', true)
18
18
  .where('deleted_at' as any, 'is', null as any)
19
- .executeTakeFirst()
19
+ .limit(2)
20
+ .execute()
20
21
 
21
- if (!row) return null
22
+ if (rows.length !== 1) return null
22
23
 
23
- return this.em.map(SsoConfig, row as Record<string, unknown>)
24
+ return this.em.map(SsoConfig, rows[0] as Record<string, unknown>)
24
25
  }
25
26
  }
@@ -34,7 +34,8 @@ export class ScimTokenService {
34
34
  scope: SsoAdminScope,
35
35
  ): Promise<ScimTokenCreateResult> {
36
36
  const where: Record<string, unknown> = { id: ssoConfigId, deletedAt: null }
37
- if (!scope.isSuperAdmin && scope.organizationId) {
37
+ if (!scope.isSuperAdmin) {
38
+ if (!scope.organizationId) throw new ScimTokenError('Organization context is required', 403)
38
39
  where.organizationId = scope.organizationId
39
40
  }
40
41
  const config = await this.em.findOne(SsoConfig, where)
@@ -54,8 +55,8 @@ export class ScimTokenService {
54
55
  tokenPrefix,
55
56
  isActive: true,
56
57
  createdBy: null,
57
- tenantId: scope.tenantId,
58
- organizationId: scope.organizationId!,
58
+ tenantId: config.tenantId ?? null,
59
+ organizationId: config.organizationId,
59
60
  } as RequiredEntityData<ScimToken>)
60
61
 
61
62
  await this.em.persist(token).flush()
@@ -201,38 +201,45 @@ export class SsoConfigService {
201
201
  }
202
202
 
203
203
  async activate(scope: SsoAdminScope, id: string, active: boolean): Promise<SsoConfigPublic> {
204
- const config = await this.resolveConfig(scope, id)
204
+ return this.em.transactional(async (txEm) => {
205
+ const tx = txEm as EntityManager
206
+ await this.lockActiveDomainMutation(tx, id)
207
+ const config = await this.resolveConfig(scope, id, tx)
208
+
209
+ if (active) {
210
+ if (config.allowedDomains.length === 0) {
211
+ throw new SsoConfigError('Cannot activate SSO configuration with no allowed domains', 400)
212
+ }
205
213
 
206
- if (active) {
207
- if (config.allowedDomains.length === 0) {
208
- throw new SsoConfigError('Cannot activate SSO configuration with no allowed domains', 400)
209
- }
214
+ await this.lockActiveDomains(tx, config.allowedDomains)
215
+ await this.assertActiveDomainAvailability(tx, config.allowedDomains, config.id)
210
216
 
211
- const testResult = await this.testConnectionInternal(config)
212
- if (!testResult.ok) {
213
- throw new SsoConfigError(`Cannot activate — discovery failed: ${testResult.error}`, 400)
217
+ const testResult = await this.testConnectionInternal(config)
218
+ if (!testResult.ok) {
219
+ throw new SsoConfigError(`Cannot activate — discovery failed: ${testResult.error}`, 400)
220
+ }
214
221
  }
215
- }
216
-
217
- const wasActive = config.isActive
218
- config.isActive = active
219
- await this.em.flush()
220
222
 
221
- if (active && !wasActive) {
222
- void emitSsoEvent('sso.config.activated', {
223
- id: config.id,
224
- tenantId: config.tenantId,
225
- organizationId: config.organizationId,
226
- }).catch((e) => console.error('[SSO Event]', e))
227
- } else if (!active && wasActive) {
228
- void emitSsoEvent('sso.config.deactivated', {
229
- id: config.id,
230
- tenantId: config.tenantId,
231
- organizationId: config.organizationId,
232
- }).catch((e) => console.error('[SSO Event]', e))
233
- }
223
+ const wasActive = config.isActive
224
+ config.isActive = active
225
+ await tx.flush()
226
+
227
+ if (active && !wasActive) {
228
+ void emitSsoEvent('sso.config.activated', {
229
+ id: config.id,
230
+ tenantId: config.tenantId,
231
+ organizationId: config.organizationId,
232
+ }).catch((e) => console.error('[SSO Event]', e))
233
+ } else if (!active && wasActive) {
234
+ void emitSsoEvent('sso.config.deactivated', {
235
+ id: config.id,
236
+ tenantId: config.tenantId,
237
+ organizationId: config.organizationId,
238
+ }).catch((e) => console.error('[SSO Event]', e))
239
+ }
234
240
 
235
- return this.toPublic(config)
241
+ return this.toPublic(config)
242
+ })
236
243
  }
237
244
 
238
245
  async testConnection(scope: SsoAdminScope, id: string): Promise<{ ok: boolean; error?: string }> {
@@ -245,26 +252,34 @@ export class SsoConfigService {
245
252
  const validation = validateDomain(normalized)
246
253
  if (!validation.valid) throw new SsoConfigError(validation.error!, 400)
247
254
 
248
- const config = await this.resolveConfig(scope, id)
255
+ return this.em.transactional(async (txEm) => {
256
+ const tx = txEm as EntityManager
257
+ await this.lockActiveDomainMutation(tx, id, [normalized])
258
+ const config = await this.resolveConfig(scope, id, tx)
249
259
 
250
- if (config.allowedDomains.includes(normalized)) {
251
- return this.toPublic(config)
252
- }
260
+ if (config.allowedDomains.some((d) => normalizeDomain(d) === normalized)) {
261
+ return this.toPublic(config)
262
+ }
253
263
 
254
- const limitCheck = checkDomainLimit(config.allowedDomains.length, 1)
255
- if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error!, 400)
264
+ const limitCheck = checkDomainLimit(config.allowedDomains.length, 1)
265
+ if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error!, 400)
256
266
 
257
- config.allowedDomains = [...config.allowedDomains, normalized]
258
- await this.em.flush()
267
+ if (config.isActive) {
268
+ await this.assertActiveDomainAvailability(tx, [...config.allowedDomains, normalized], config.id)
269
+ }
259
270
 
260
- void emitSsoEvent('sso.domain.added', {
261
- id: config.id,
262
- tenantId: config.tenantId,
263
- organizationId: config.organizationId,
264
- domain: normalized,
265
- }).catch((e) => console.error('[SSO Event]', e))
271
+ config.allowedDomains = [...config.allowedDomains, normalized]
272
+ await tx.flush()
266
273
 
267
- return this.toPublic(config)
274
+ void emitSsoEvent('sso.domain.added', {
275
+ id: config.id,
276
+ tenantId: config.tenantId,
277
+ organizationId: config.organizationId,
278
+ domain: normalized,
279
+ }).catch((e) => console.error('[SSO Event]', e))
280
+
281
+ return this.toPublic(config)
282
+ })
268
283
  }
269
284
 
270
285
  async removeDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {
@@ -305,14 +320,14 @@ export class SsoConfigService {
305
320
  }
306
321
  }
307
322
 
308
- private async resolveConfig(scope: SsoAdminScope, id: string): Promise<SsoConfig> {
323
+ private async resolveConfig(scope: SsoAdminScope, id: string, em: EntityManager = this.em): Promise<SsoConfig> {
309
324
  const where: FilterQuery<SsoConfig> = { id, deletedAt: null }
310
325
  if (!scope.isSuperAdmin) {
311
326
  if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)
312
327
  where.organizationId = scope.organizationId
313
328
  }
314
329
 
315
- const config = await this.em.findOne(SsoConfig, where)
330
+ const config = await em.findOne(SsoConfig, where)
316
331
  if (!config) throw new SsoConfigError('SSO configuration not found', 404)
317
332
 
318
333
  return config
@@ -324,6 +339,58 @@ export class SsoConfigService {
324
339
 
325
340
  return provider.validateConfig(config)
326
341
  }
342
+
343
+ private async lockActiveDomainMutation(em: EntityManager, configId: string, domains: string[] = []): Promise<void> {
344
+ const lockKeys = [
345
+ `sso:sso_config:${configId}`,
346
+ ...uniqueDomains(domains)
347
+ .sort((a, b) => a.localeCompare(b))
348
+ .map((domain) => `sso:allowed_domain:${domain}`),
349
+ ]
350
+
351
+ await this.lockKeys(em, lockKeys)
352
+ }
353
+
354
+ private async lockActiveDomains(em: EntityManager, domains: string[]): Promise<void> {
355
+ const lockKeys = uniqueDomains(domains)
356
+ .sort((a, b) => a.localeCompare(b))
357
+ .map((domain) => `sso:allowed_domain:${domain}`)
358
+ await this.lockKeys(em, lockKeys)
359
+ }
360
+
361
+ private async lockKeys(em: EntityManager, lockKeys: string[]): Promise<void> {
362
+ for (const lockKey of lockKeys) {
363
+ await em.getConnection().execute('select pg_advisory_xact_lock(hashtext(?::text))', [lockKey])
364
+ }
365
+ }
366
+
367
+ private async assertActiveDomainAvailability(
368
+ em: EntityManager,
369
+ domains: string[],
370
+ currentConfigId: string,
371
+ ): Promise<void> {
372
+ const requestedDomains = new Set(uniqueDomains(domains))
373
+ if (requestedDomains.size === 0) return
374
+
375
+ const activeConfigs = await findWithDecryption(
376
+ em,
377
+ SsoConfig,
378
+ { isActive: true, deletedAt: null },
379
+ {},
380
+ { tenantId: null },
381
+ )
382
+ const conflictDomain = activeConfigs
383
+ .filter((config) => config.id !== currentConfigId)
384
+ .flatMap((config) => config.allowedDomains.map(normalizeDomain))
385
+ .find((domain) => requestedDomains.has(domain))
386
+
387
+ if (conflictDomain) {
388
+ throw new SsoConfigError(
389
+ `SSO domain "${conflictDomain}" is already claimed by another active SSO configuration`,
390
+ 409,
391
+ )
392
+ }
393
+ }
327
394
  }
328
395
 
329
396
  export class SsoConfigError extends Error {
@@ -32,7 +32,8 @@ export class SsoService {
32
32
  {},
33
33
  { tenantId: null },
34
34
  )
35
- return configs.find((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain)) ?? null
35
+ const matches = configs.filter((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain))
36
+ return matches.length === 1 ? matches[0]! : null
36
37
  }
37
38
 
38
39
  async initiateLogin(