@open-mercato/enterprise 0.6.6-develop.6431.1.g1037269810 → 0.6.6-develop.6451.1.5b071f47fc
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.
- package/dist/modules/sso/api/scim/logs/route.js +14 -5
- package/dist/modules/sso/api/scim/logs/route.js.map +2 -2
- package/dist/modules/sso/services/hrdService.js +3 -3
- package/dist/modules/sso/services/hrdService.js.map +2 -2
- package/dist/modules/sso/services/scimTokenService.js +4 -3
- package/dist/modules/sso/services/scimTokenService.js.map +2 -2
- package/dist/modules/sso/services/ssoConfigService.js +89 -41
- package/dist/modules/sso/services/ssoConfigService.js.map +2 -2
- package/dist/modules/sso/services/ssoService.js +2 -1
- package/dist/modules/sso/services/ssoService.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/sso/api/scim/logs/route.ts +16 -7
- package/src/modules/sso/services/hrdService.ts +5 -4
- package/src/modules/sso/services/scimTokenService.ts +4 -3
- package/src/modules/sso/services/ssoConfigService.ts +111 -44
- package/src/modules/sso/services/ssoService.ts +2 -1
|
@@ -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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;
|
|
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
|
|
12
|
-
if (
|
|
13
|
-
return this.em.map(SsoConfig,
|
|
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
|
|
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,
|
|
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
|
|
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:
|
|
31
|
-
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
|
|
5
|
-
"mappings": "AAAA,SAAS,mBAAmB;AAG5B,SAAS,MAAM,eAAe;AAC9B,SAAS,WAAW,iBAAiB;AAGrC,MAAM,cAAc;AACpB,MAAM,eAAe;AAmBd,MAAM,iBAAiB;AAAA,EAC5B,YAAoB,IAAmB;AAAnB;AAAA,EAAoB;AAAA,EAExC,MAAM,cACJ,aACA,MACA,OACgC;AAChC,UAAM,QAAiC,EAAE,IAAI,aAAa,WAAW,KAAK;AAC1E,QAAI,CAAC,MAAM,
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
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": "
|
|
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
|
-
|
|
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
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AAEnB,SAAS,uBAAuB,0BAA0B;AAC1D,SAAS,eAAe;AAGxB,SAAS,iBAAiB;AAG1B,SAAS,oBAAoB,oBAAoB,uBAAuB;AACxE,SAAS,oBAAoB;AAGtB,MAAM,WAAW;AAAA,EACtB,YACU,IACA,qBACA,uBACA,yBACA,aACA,aACR;AANQ;AACA;AACA;AACA;AACA;AACA;AAAA,EACP;AAAA,EAEH,MAAM,kBAAkB,OAA0C;AAChE,UAAM,SAAS,MAAM,MAAM,GAAG,EAAE,CAAC,GAAG,YAAY;AAChD,QAAI,CAAC,OAAQ,QAAO;AAEpB,UAAM,UAAU,MAAM;AAAA,MACpB,KAAK;AAAA,MACL;AAAA,MACA,EAAE,UAAU,MAAM,WAAW,KAAK;AAAA,MAClC,CAAC;AAAA,MACD,EAAE,UAAU,KAAK;AAAA,IACnB;AACA,
|
|
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.
|
|
3
|
+
"version": "0.6.6-develop.6451.1.5b071f47fc",
|
|
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.
|
|
69
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
68
|
+
"@open-mercato/core": "0.6.6-develop.6451.1.5b071f47fc",
|
|
69
|
+
"@open-mercato/ui": "0.6.6-develop.6451.1.5b071f47fc",
|
|
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.
|
|
79
|
+
"@open-mercato/shared": "0.6.6-develop.6451.1.5b071f47fc",
|
|
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.
|
|
84
|
+
"@open-mercato/shared": "0.6.6-develop.6451.1.5b071f47fc",
|
|
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
|
|
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:
|
|
24
|
-
if (!scope.isSuperAdmin
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
.
|
|
19
|
+
.limit(2)
|
|
20
|
+
.execute()
|
|
20
21
|
|
|
21
|
-
if (
|
|
22
|
+
if (rows.length !== 1) return null
|
|
22
23
|
|
|
23
|
-
return this.em.map(SsoConfig,
|
|
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
|
|
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:
|
|
58
|
-
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
260
|
+
if (config.allowedDomains.some((d) => normalizeDomain(d) === normalized)) {
|
|
261
|
+
return this.toPublic(config)
|
|
262
|
+
}
|
|
253
263
|
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
const limitCheck = checkDomainLimit(config.allowedDomains.length, 1)
|
|
265
|
+
if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error!, 400)
|
|
256
266
|
|
|
257
|
-
|
|
258
|
-
|
|
267
|
+
if (config.isActive) {
|
|
268
|
+
await this.assertActiveDomainAvailability(tx, [...config.allowedDomains, normalized], config.id)
|
|
269
|
+
}
|
|
259
270
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|