@open-mercato/enterprise 0.4.6-develop-15c18897fc → 0.4.6-develop-34aa847ce6
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/index.js +1 -1
- package/dist/index.js.map +2 -2
- package/dist/modules/sso/acl.js +11 -0
- package/dist/modules/sso/acl.js.map +7 -0
- package/dist/modules/sso/api/admin-context.js +27 -0
- package/dist/modules/sso/api/admin-context.js.map +7 -0
- package/dist/modules/sso/api/callback/oidc/route.js +103 -0
- package/dist/modules/sso/api/callback/oidc/route.js.map +7 -0
- package/dist/modules/sso/api/config/[id]/activate/route.js +49 -0
- package/dist/modules/sso/api/config/[id]/activate/route.js.map +7 -0
- package/dist/modules/sso/api/config/[id]/domains/route.js +96 -0
- package/dist/modules/sso/api/config/[id]/domains/route.js.map +7 -0
- package/dist/modules/sso/api/config/[id]/route.js +103 -0
- package/dist/modules/sso/api/config/[id]/route.js.map +7 -0
- package/dist/modules/sso/api/config/[id]/test/route.js +41 -0
- package/dist/modules/sso/api/config/[id]/test/route.js.map +7 -0
- package/dist/modules/sso/api/config/route.js +83 -0
- package/dist/modules/sso/api/config/route.js.map +7 -0
- package/dist/modules/sso/api/error-handler.js +28 -0
- package/dist/modules/sso/api/error-handler.js.map +7 -0
- package/dist/modules/sso/api/hrd/route.js +52 -0
- package/dist/modules/sso/api/hrd/route.js.map +7 -0
- package/dist/modules/sso/api/initiate/route.js +66 -0
- package/dist/modules/sso/api/initiate/route.js.map +7 -0
- package/dist/modules/sso/api/scim/context.js +68 -0
- package/dist/modules/sso/api/scim/context.js.map +7 -0
- package/dist/modules/sso/api/scim/logs/route.js +65 -0
- package/dist/modules/sso/api/scim/logs/route.js.map +7 -0
- package/dist/modules/sso/api/scim/tokens/[id]/route.js +42 -0
- package/dist/modules/sso/api/scim/tokens/[id]/route.js.map +7 -0
- package/dist/modules/sso/api/scim/tokens/route.js +83 -0
- package/dist/modules/sso/api/scim/tokens/route.js.map +7 -0
- package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js +42 -0
- package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js.map +7 -0
- package/dist/modules/sso/api/scim/v2/Users/[id]/route.js +94 -0
- package/dist/modules/sso/api/scim/v2/Users/[id]/route.js.map +7 -0
- package/dist/modules/sso/api/scim/v2/Users/route.js +86 -0
- package/dist/modules/sso/api/scim/v2/Users/route.js.map +7 -0
- package/dist/modules/sso/backend/page.js +173 -0
- package/dist/modules/sso/backend/page.js.map +7 -0
- package/dist/modules/sso/backend/page.meta.js +31 -0
- package/dist/modules/sso/backend/page.meta.js.map +7 -0
- package/dist/modules/sso/backend/sso/config/[id]/page.js +749 -0
- package/dist/modules/sso/backend/sso/config/[id]/page.js.map +7 -0
- package/dist/modules/sso/backend/sso/config/[id]/page.meta.js +19 -0
- package/dist/modules/sso/backend/sso/config/[id]/page.meta.js.map +7 -0
- package/dist/modules/sso/backend/sso/config/new/page.js +381 -0
- package/dist/modules/sso/backend/sso/config/new/page.js.map +7 -0
- package/dist/modules/sso/backend/sso/config/new/page.meta.js +19 -0
- package/dist/modules/sso/backend/sso/config/new/page.meta.js.map +7 -0
- package/dist/modules/sso/data/entities.js +299 -0
- package/dist/modules/sso/data/entities.js.map +7 -0
- package/dist/modules/sso/data/validators.js +114 -0
- package/dist/modules/sso/data/validators.js.map +7 -0
- package/dist/modules/sso/di.js +26 -0
- package/dist/modules/sso/di.js.map +7 -0
- package/dist/modules/sso/events.js +24 -0
- package/dist/modules/sso/events.js.map +7 -0
- package/dist/modules/sso/i18n/de.json +146 -0
- package/dist/modules/sso/i18n/en.json +146 -0
- package/dist/modules/sso/i18n/es.json +146 -0
- package/dist/modules/sso/i18n/pl.json +146 -0
- package/dist/modules/sso/index.js +11 -0
- package/dist/modules/sso/index.js.map +7 -0
- package/dist/modules/sso/lib/domains.js +30 -0
- package/dist/modules/sso/lib/domains.js.map +7 -0
- package/dist/modules/sso/lib/oidc-provider.js +140 -0
- package/dist/modules/sso/lib/oidc-provider.js.map +7 -0
- package/dist/modules/sso/lib/registry.js +15 -0
- package/dist/modules/sso/lib/registry.js.map +7 -0
- package/dist/modules/sso/lib/scim-filter.js +43 -0
- package/dist/modules/sso/lib/scim-filter.js.map +7 -0
- package/dist/modules/sso/lib/scim-mapper.js +49 -0
- package/dist/modules/sso/lib/scim-mapper.js.map +7 -0
- package/dist/modules/sso/lib/scim-patch.js +63 -0
- package/dist/modules/sso/lib/scim-patch.js.map +7 -0
- package/dist/modules/sso/lib/scim-response.js +34 -0
- package/dist/modules/sso/lib/scim-response.js.map +7 -0
- package/dist/modules/sso/lib/scim-utils.js +9 -0
- package/dist/modules/sso/lib/scim-utils.js.map +7 -0
- package/dist/modules/sso/lib/state-cookie.js +67 -0
- package/dist/modules/sso/lib/state-cookie.js.map +7 -0
- package/dist/modules/sso/lib/types.js +1 -0
- package/dist/modules/sso/lib/types.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260219000000_sso.js +20 -0
- package/dist/modules/sso/migrations/Migration20260219000000_sso.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js +13 -0
- package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js +15 -0
- package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js +22 -0
- package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js +15 -0
- package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js +17 -0
- package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js +13 -0
- package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js +23 -0
- package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js.map +7 -0
- package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js +14 -0
- package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js.map +7 -0
- package/dist/modules/sso/services/accountLinkingService.js +298 -0
- package/dist/modules/sso/services/accountLinkingService.js.map +7 -0
- package/dist/modules/sso/services/hrdService.js +18 -0
- package/dist/modules/sso/services/hrdService.js.map +7 -0
- package/dist/modules/sso/services/scimService.js +372 -0
- package/dist/modules/sso/services/scimService.js.map +7 -0
- package/dist/modules/sso/services/scimTokenService.js +94 -0
- package/dist/modules/sso/services/scimTokenService.js.map +7 -0
- package/dist/modules/sso/services/ssoConfigService.js +254 -0
- package/dist/modules/sso/services/ssoConfigService.js.map +7 -0
- package/dist/modules/sso/services/ssoService.js +125 -0
- package/dist/modules/sso/services/ssoService.js.map +7 -0
- package/dist/modules/sso/setup.js +47 -0
- package/dist/modules/sso/setup.js.map +7 -0
- package/dist/modules/sso/subscribers/user-deleted-cleanup.js +21 -0
- package/dist/modules/sso/subscribers/user-deleted-cleanup.js.map +7 -0
- package/dist/modules/sso/widgets/injection/login-sso/widget.client.js +106 -0
- package/dist/modules/sso/widgets/injection/login-sso/widget.client.js.map +7 -0
- package/dist/modules/sso/widgets/injection/login-sso/widget.js +16 -0
- package/dist/modules/sso/widgets/injection/login-sso/widget.js.map +7 -0
- package/dist/modules/sso/widgets/injection-table.js +14 -0
- package/dist/modules/sso/widgets/injection-table.js.map +7 -0
- package/package.json +5 -4
- package/src/index.ts +1 -1
- package/src/modules/sso/acl.ts +7 -0
- package/src/modules/sso/api/admin-context.ts +36 -0
- package/src/modules/sso/api/callback/oidc/route.ts +115 -0
- package/src/modules/sso/api/config/[id]/activate/route.ts +53 -0
- package/src/modules/sso/api/config/[id]/domains/route.ts +107 -0
- package/src/modules/sso/api/config/[id]/route.ts +114 -0
- package/src/modules/sso/api/config/[id]/test/route.ts +44 -0
- package/src/modules/sso/api/config/route.ts +88 -0
- package/src/modules/sso/api/error-handler.ts +36 -0
- package/src/modules/sso/api/hrd/route.ts +55 -0
- package/src/modules/sso/api/initiate/route.ts +70 -0
- package/src/modules/sso/api/scim/context.ts +85 -0
- package/src/modules/sso/api/scim/logs/route.ts +69 -0
- package/src/modules/sso/api/scim/tokens/[id]/route.ts +45 -0
- package/src/modules/sso/api/scim/tokens/route.ts +89 -0
- package/src/modules/sso/api/scim/v2/ServiceProviderConfig/route.ts +40 -0
- package/src/modules/sso/api/scim/v2/Users/[id]/route.ts +103 -0
- package/src/modules/sso/api/scim/v2/Users/route.ts +94 -0
- package/src/modules/sso/backend/page.meta.ts +29 -0
- package/src/modules/sso/backend/page.tsx +232 -0
- package/src/modules/sso/backend/sso/config/[id]/page.meta.ts +15 -0
- package/src/modules/sso/backend/sso/config/[id]/page.tsx +1024 -0
- package/src/modules/sso/backend/sso/config/new/page.meta.ts +15 -0
- package/src/modules/sso/backend/sso/config/new/page.tsx +463 -0
- package/src/modules/sso/data/entities.ts +240 -0
- package/src/modules/sso/data/validators.ts +140 -0
- package/src/modules/sso/di.ts +25 -0
- package/src/modules/sso/docs/entra-id-setup.md +281 -0
- package/src/modules/sso/docs/google-workspace-setup.md +174 -0
- package/src/modules/sso/docs/sso-overview.md +218 -0
- package/src/modules/sso/docs/sso-security-audit-2026-02-27.md +118 -0
- package/src/modules/sso/docs/zitadel-setup.md +195 -0
- package/src/modules/sso/events.ts +21 -0
- package/src/modules/sso/i18n/de.json +146 -0
- package/src/modules/sso/i18n/en.json +146 -0
- package/src/modules/sso/i18n/es.json +146 -0
- package/src/modules/sso/i18n/pl.json +146 -0
- package/src/modules/sso/index.ts +7 -0
- package/src/modules/sso/lib/domains.ts +31 -0
- package/src/modules/sso/lib/oidc-provider.ts +196 -0
- package/src/modules/sso/lib/registry.ts +13 -0
- package/src/modules/sso/lib/scim-filter.ts +62 -0
- package/src/modules/sso/lib/scim-mapper.ts +88 -0
- package/src/modules/sso/lib/scim-patch.ts +88 -0
- package/src/modules/sso/lib/scim-response.ts +40 -0
- package/src/modules/sso/lib/scim-utils.ts +5 -0
- package/src/modules/sso/lib/state-cookie.ts +79 -0
- package/src/modules/sso/lib/types.ts +50 -0
- package/src/modules/sso/migrations/.snapshot-open-mercato.json +912 -0
- package/src/modules/sso/migrations/Migration20260219000000_sso.ts +21 -0
- package/src/modules/sso/migrations/Migration20260222000000_sso_add_name.ts +13 -0
- package/src/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.ts +15 -0
- package/src/modules/sso/migrations/Migration20260223000000_scim_tables.ts +24 -0
- package/src/modules/sso/migrations/Migration20260224000000_sso_external_id.ts +15 -0
- package/src/modules/sso/migrations/Migration20260224100000_sso_role_grants.ts +18 -0
- package/src/modules/sso/migrations/Migration20260224200000_drop_default_role_id.ts +13 -0
- package/src/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.ts +25 -0
- package/src/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.ts +14 -0
- package/src/modules/sso/services/accountLinkingService.ts +386 -0
- package/src/modules/sso/services/hrdService.ts +22 -0
- package/src/modules/sso/services/scimService.ts +461 -0
- package/src/modules/sso/services/scimTokenService.ts +136 -0
- package/src/modules/sso/services/ssoConfigService.ts +337 -0
- package/src/modules/sso/services/ssoService.ts +167 -0
- package/src/modules/sso/setup.ts +56 -0
- package/src/modules/sso/subscribers/user-deleted-cleanup.ts +33 -0
- package/src/modules/sso/widgets/injection/login-sso/widget.client.tsx +130 -0
- package/src/modules/sso/widgets/injection/login-sso/widget.ts +16 -0
- package/src/modules/sso/widgets/injection-table.ts +12 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
2
|
+
import { SsoConfig, ScimToken } from "../data/entities.js";
|
|
3
|
+
import { emitSsoEvent } from "../events.js";
|
|
4
|
+
import { validateDomain, normalizeDomain, uniqueDomains, checkDomainLimit } from "../lib/domains.js";
|
|
5
|
+
class SsoConfigService {
|
|
6
|
+
constructor(em, tenantEncryptionService, ssoProviderRegistry) {
|
|
7
|
+
this.em = em;
|
|
8
|
+
this.tenantEncryptionService = tenantEncryptionService;
|
|
9
|
+
this.ssoProviderRegistry = ssoProviderRegistry;
|
|
10
|
+
}
|
|
11
|
+
async list(scope, query) {
|
|
12
|
+
const where = { deletedAt: null };
|
|
13
|
+
if (!scope.isSuperAdmin) {
|
|
14
|
+
if (!scope.organizationId) {
|
|
15
|
+
throw new SsoConfigError("Organization context is required", 403);
|
|
16
|
+
}
|
|
17
|
+
where.organizationId = scope.organizationId;
|
|
18
|
+
} else {
|
|
19
|
+
if (query.organizationId) where.organizationId = query.organizationId;
|
|
20
|
+
if (query.tenantId) where.tenantId = query.tenantId;
|
|
21
|
+
}
|
|
22
|
+
if (query.search) {
|
|
23
|
+
const pattern = `%${escapeLikePattern(query.search)}%`;
|
|
24
|
+
where.$or = [
|
|
25
|
+
{ name: { $ilike: pattern } },
|
|
26
|
+
{ issuer: { $ilike: pattern } },
|
|
27
|
+
{ clientId: { $ilike: pattern } }
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
const [configs, total] = await this.em.findAndCount(SsoConfig, where, {
|
|
31
|
+
orderBy: { createdAt: "desc" },
|
|
32
|
+
limit: query.pageSize,
|
|
33
|
+
offset: (query.page - 1) * query.pageSize
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
items: configs.map((c) => this.toPublic(c)),
|
|
37
|
+
total,
|
|
38
|
+
totalPages: Math.ceil(total / query.pageSize) || 1
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async getById(scope, id) {
|
|
42
|
+
const where = { id, deletedAt: null };
|
|
43
|
+
if (!scope.isSuperAdmin) {
|
|
44
|
+
if (!scope.organizationId) throw new SsoConfigError("Organization context is required", 403);
|
|
45
|
+
where.organizationId = scope.organizationId;
|
|
46
|
+
}
|
|
47
|
+
const config = await this.em.findOne(SsoConfig, where);
|
|
48
|
+
return config ? this.toPublic(config) : null;
|
|
49
|
+
}
|
|
50
|
+
async create(scope, input) {
|
|
51
|
+
const orgId = scope.isSuperAdmin ? input.organizationId : scope.organizationId;
|
|
52
|
+
const tenId = scope.isSuperAdmin ? input.tenantId ?? null : scope.tenantId;
|
|
53
|
+
const existing = await this.em.findOne(SsoConfig, {
|
|
54
|
+
organizationId: orgId,
|
|
55
|
+
deletedAt: null
|
|
56
|
+
});
|
|
57
|
+
if (existing) {
|
|
58
|
+
throw new SsoConfigError("An SSO configuration already exists for this organization", 409);
|
|
59
|
+
}
|
|
60
|
+
const domains = uniqueDomains(input.allowedDomains);
|
|
61
|
+
for (const d of domains) {
|
|
62
|
+
const result = validateDomain(d);
|
|
63
|
+
if (!result.valid) throw new SsoConfigError(`Invalid domain "${d}": ${result.error}`, 400);
|
|
64
|
+
}
|
|
65
|
+
const encrypted = await this.tenantEncryptionService.encryptEntityPayload(
|
|
66
|
+
"SsoConfig",
|
|
67
|
+
{ clientSecretEnc: input.clientSecret },
|
|
68
|
+
tenId,
|
|
69
|
+
orgId
|
|
70
|
+
);
|
|
71
|
+
const config = this.em.create(SsoConfig, {
|
|
72
|
+
name: input.name,
|
|
73
|
+
tenantId: tenId,
|
|
74
|
+
organizationId: orgId,
|
|
75
|
+
protocol: input.protocol,
|
|
76
|
+
issuer: input.issuer,
|
|
77
|
+
clientId: input.clientId,
|
|
78
|
+
clientSecretEnc: encrypted.clientSecretEnc,
|
|
79
|
+
allowedDomains: domains,
|
|
80
|
+
jitEnabled: input.jitEnabled,
|
|
81
|
+
autoLinkByEmail: input.autoLinkByEmail,
|
|
82
|
+
isActive: false,
|
|
83
|
+
ssoRequired: false,
|
|
84
|
+
appRoleMappings: input.appRoleMappings ?? {}
|
|
85
|
+
});
|
|
86
|
+
await this.em.persistAndFlush(config);
|
|
87
|
+
void emitSsoEvent("sso.config.created", {
|
|
88
|
+
id: config.id,
|
|
89
|
+
tenantId: config.tenantId,
|
|
90
|
+
organizationId: config.organizationId
|
|
91
|
+
}).catch((e) => console.error("[SSO Event]", e));
|
|
92
|
+
return this.toPublic(config);
|
|
93
|
+
}
|
|
94
|
+
async update(scope, id, input) {
|
|
95
|
+
const config = await this.resolveConfig(scope, id);
|
|
96
|
+
if (input.name !== void 0) config.name = input.name;
|
|
97
|
+
if (input.protocol !== void 0) config.protocol = input.protocol;
|
|
98
|
+
if (input.issuer !== void 0) config.issuer = input.issuer;
|
|
99
|
+
if (input.clientId !== void 0) config.clientId = input.clientId;
|
|
100
|
+
if (input.jitEnabled !== void 0) {
|
|
101
|
+
if (input.jitEnabled) {
|
|
102
|
+
const activeScimCount = await this.em.count(ScimToken, { ssoConfigId: id, isActive: true });
|
|
103
|
+
if (activeScimCount > 0) {
|
|
104
|
+
throw new SsoConfigError("Cannot enable JIT provisioning while SCIM directory sync is active. Revoke all SCIM tokens first.", 409);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
config.jitEnabled = input.jitEnabled;
|
|
108
|
+
}
|
|
109
|
+
if (input.autoLinkByEmail !== void 0) config.autoLinkByEmail = input.autoLinkByEmail;
|
|
110
|
+
if (input.appRoleMappings !== void 0) config.appRoleMappings = input.appRoleMappings;
|
|
111
|
+
if (input.clientSecret !== void 0) {
|
|
112
|
+
const encrypted = await this.tenantEncryptionService.encryptEntityPayload(
|
|
113
|
+
"SsoConfig",
|
|
114
|
+
{ clientSecretEnc: input.clientSecret },
|
|
115
|
+
config.tenantId,
|
|
116
|
+
config.organizationId
|
|
117
|
+
);
|
|
118
|
+
config.clientSecretEnc = encrypted.clientSecretEnc;
|
|
119
|
+
}
|
|
120
|
+
await this.em.flush();
|
|
121
|
+
void emitSsoEvent("sso.config.updated", {
|
|
122
|
+
id: config.id,
|
|
123
|
+
tenantId: config.tenantId,
|
|
124
|
+
organizationId: config.organizationId
|
|
125
|
+
}).catch((e) => console.error("[SSO Event]", e));
|
|
126
|
+
return this.toPublic(config);
|
|
127
|
+
}
|
|
128
|
+
async delete(scope, id) {
|
|
129
|
+
const config = await this.resolveConfig(scope, id);
|
|
130
|
+
if (config.isActive) {
|
|
131
|
+
throw new SsoConfigError("Cannot delete an active SSO configuration \u2014 deactivate it first", 400);
|
|
132
|
+
}
|
|
133
|
+
config.deletedAt = /* @__PURE__ */ new Date();
|
|
134
|
+
await this.em.flush();
|
|
135
|
+
void emitSsoEvent("sso.config.deleted", {
|
|
136
|
+
id: config.id,
|
|
137
|
+
tenantId: config.tenantId,
|
|
138
|
+
organizationId: config.organizationId
|
|
139
|
+
}).catch((e) => console.error("[SSO Event]", e));
|
|
140
|
+
}
|
|
141
|
+
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);
|
|
146
|
+
}
|
|
147
|
+
const testResult = await this.testConnectionInternal(config);
|
|
148
|
+
if (!testResult.ok) {
|
|
149
|
+
throw new SsoConfigError(`Cannot activate \u2014 discovery failed: ${testResult.error}`, 400);
|
|
150
|
+
}
|
|
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);
|
|
169
|
+
}
|
|
170
|
+
async testConnection(scope, id) {
|
|
171
|
+
const config = await this.resolveConfig(scope, id);
|
|
172
|
+
return this.testConnectionInternal(config);
|
|
173
|
+
}
|
|
174
|
+
async addDomain(scope, id, domain) {
|
|
175
|
+
const normalized = normalizeDomain(domain);
|
|
176
|
+
const validation = validateDomain(normalized);
|
|
177
|
+
if (!validation.valid) throw new SsoConfigError(validation.error, 400);
|
|
178
|
+
const config = await this.resolveConfig(scope, id);
|
|
179
|
+
if (config.allowedDomains.includes(normalized)) {
|
|
180
|
+
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);
|
|
193
|
+
}
|
|
194
|
+
async removeDomain(scope, id, domain) {
|
|
195
|
+
const normalized = normalizeDomain(domain);
|
|
196
|
+
const config = await this.resolveConfig(scope, id);
|
|
197
|
+
config.allowedDomains = config.allowedDomains.filter((d) => d !== normalized);
|
|
198
|
+
await this.em.flush();
|
|
199
|
+
void emitSsoEvent("sso.domain.removed", {
|
|
200
|
+
id: config.id,
|
|
201
|
+
tenantId: config.tenantId,
|
|
202
|
+
organizationId: config.organizationId,
|
|
203
|
+
domain: normalized
|
|
204
|
+
}).catch((e) => console.error("[SSO Event]", e));
|
|
205
|
+
return this.toPublic(config);
|
|
206
|
+
}
|
|
207
|
+
toPublic(config) {
|
|
208
|
+
return {
|
|
209
|
+
id: config.id,
|
|
210
|
+
name: config.name ?? null,
|
|
211
|
+
tenantId: config.tenantId ?? null,
|
|
212
|
+
organizationId: config.organizationId,
|
|
213
|
+
protocol: config.protocol,
|
|
214
|
+
issuer: config.issuer ?? null,
|
|
215
|
+
clientId: config.clientId ?? null,
|
|
216
|
+
hasClientSecret: !!config.clientSecretEnc,
|
|
217
|
+
allowedDomains: config.allowedDomains,
|
|
218
|
+
jitEnabled: config.jitEnabled,
|
|
219
|
+
autoLinkByEmail: config.autoLinkByEmail,
|
|
220
|
+
isActive: config.isActive,
|
|
221
|
+
ssoRequired: config.ssoRequired,
|
|
222
|
+
appRoleMappings: config.appRoleMappings ?? {},
|
|
223
|
+
createdAt: config.createdAt,
|
|
224
|
+
updatedAt: config.updatedAt
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async resolveConfig(scope, id) {
|
|
228
|
+
const where = { id, deletedAt: null };
|
|
229
|
+
if (!scope.isSuperAdmin) {
|
|
230
|
+
if (!scope.organizationId) throw new SsoConfigError("Organization context is required", 403);
|
|
231
|
+
where.organizationId = scope.organizationId;
|
|
232
|
+
}
|
|
233
|
+
const config = await this.em.findOne(SsoConfig, where);
|
|
234
|
+
if (!config) throw new SsoConfigError("SSO configuration not found", 404);
|
|
235
|
+
return config;
|
|
236
|
+
}
|
|
237
|
+
async testConnectionInternal(config) {
|
|
238
|
+
const provider = this.ssoProviderRegistry.resolve(config.protocol);
|
|
239
|
+
if (!provider) return { ok: false, error: `No provider for protocol: ${config.protocol}` };
|
|
240
|
+
return provider.validateConfig(config);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
class SsoConfigError extends Error {
|
|
244
|
+
constructor(message, statusCode) {
|
|
245
|
+
super(message);
|
|
246
|
+
this.statusCode = statusCode;
|
|
247
|
+
this.name = "SsoConfigError";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export {
|
|
251
|
+
SsoConfigError,
|
|
252
|
+
SsoConfigService
|
|
253
|
+
};
|
|
254
|
+
//# sourceMappingURL=ssoConfigService.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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.persistAndFlush(config)\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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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
+
import { signJwt } from "@open-mercato/shared/lib/auth/jwt";
|
|
4
|
+
import { SsoConfig } from "../data/entities.js";
|
|
5
|
+
import { encryptStateCookie, decryptStateCookie, createFlowState } from "../lib/state-cookie.js";
|
|
6
|
+
import { emitSsoEvent } from "../events.js";
|
|
7
|
+
class SsoService {
|
|
8
|
+
constructor(em, ssoProviderRegistry, accountLinkingService, tenantEncryptionService, authService, rbacService) {
|
|
9
|
+
this.em = em;
|
|
10
|
+
this.ssoProviderRegistry = ssoProviderRegistry;
|
|
11
|
+
this.accountLinkingService = accountLinkingService;
|
|
12
|
+
this.tenantEncryptionService = tenantEncryptionService;
|
|
13
|
+
this.authService = authService;
|
|
14
|
+
this.rbacService = rbacService;
|
|
15
|
+
}
|
|
16
|
+
async findConfigByEmail(email) {
|
|
17
|
+
const domain = email.split("@")[1]?.toLowerCase();
|
|
18
|
+
if (!domain) return null;
|
|
19
|
+
const configs = await findWithDecryption(
|
|
20
|
+
this.em,
|
|
21
|
+
SsoConfig,
|
|
22
|
+
{ isActive: true, deletedAt: null },
|
|
23
|
+
{},
|
|
24
|
+
{ tenantId: null }
|
|
25
|
+
);
|
|
26
|
+
return configs.find((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain)) ?? null;
|
|
27
|
+
}
|
|
28
|
+
async initiateLogin(configId, returnUrl, redirectUri) {
|
|
29
|
+
const config = await findOneWithDecryption(
|
|
30
|
+
this.em,
|
|
31
|
+
SsoConfig,
|
|
32
|
+
{ id: configId, isActive: true, deletedAt: null },
|
|
33
|
+
{},
|
|
34
|
+
{ tenantId: null }
|
|
35
|
+
);
|
|
36
|
+
if (!config) throw new Error("SSO configuration not found or inactive");
|
|
37
|
+
const provider = this.ssoProviderRegistry.resolve(config.protocol);
|
|
38
|
+
if (!provider) throw new Error(`No provider registered for protocol: ${config.protocol}`);
|
|
39
|
+
const clientSecret = await this.decryptClientSecret(config);
|
|
40
|
+
const { state } = createFlowState({ configId, returnUrl });
|
|
41
|
+
void emitSsoEvent("sso.login.initiated", {
|
|
42
|
+
tenantId: config.tenantId,
|
|
43
|
+
organizationId: config.organizationId
|
|
44
|
+
}).catch((e) => console.error("[SSO Event]", e));
|
|
45
|
+
const authUrl = await provider.buildAuthUrl(config, {
|
|
46
|
+
state: state.state,
|
|
47
|
+
nonce: state.nonce,
|
|
48
|
+
redirectUri,
|
|
49
|
+
codeVerifier: state.codeVerifier,
|
|
50
|
+
clientSecret
|
|
51
|
+
});
|
|
52
|
+
const stateCookie = encryptStateCookie(state);
|
|
53
|
+
return { redirectUrl: authUrl, stateCookie };
|
|
54
|
+
}
|
|
55
|
+
async handleOidcCallback(callbackParams, stateCookie, redirectUri) {
|
|
56
|
+
const flowState = decryptStateCookie(stateCookie);
|
|
57
|
+
if (!flowState) throw new Error("Invalid or expired SSO state");
|
|
58
|
+
const receivedState = Buffer.from(callbackParams.state || "");
|
|
59
|
+
const expectedState = Buffer.from(flowState.state);
|
|
60
|
+
if (receivedState.length !== expectedState.length || !crypto.timingSafeEqual(receivedState, expectedState)) {
|
|
61
|
+
throw new Error("State mismatch \u2014 possible CSRF attack");
|
|
62
|
+
}
|
|
63
|
+
const config = await findOneWithDecryption(
|
|
64
|
+
this.em,
|
|
65
|
+
SsoConfig,
|
|
66
|
+
{ id: flowState.configId, isActive: true, deletedAt: null },
|
|
67
|
+
{},
|
|
68
|
+
{ tenantId: null }
|
|
69
|
+
);
|
|
70
|
+
if (!config) throw new Error("SSO configuration no longer active");
|
|
71
|
+
const provider = this.ssoProviderRegistry.resolve(config.protocol);
|
|
72
|
+
if (!provider) throw new Error(`No provider for protocol: ${config.protocol}`);
|
|
73
|
+
const clientSecret = await this.decryptClientSecret(config);
|
|
74
|
+
const idpPayload = await provider.handleCallback(config, {
|
|
75
|
+
callbackParams,
|
|
76
|
+
redirectUri,
|
|
77
|
+
expectedState: flowState.state,
|
|
78
|
+
expectedNonce: flowState.nonce,
|
|
79
|
+
codeVerifier: flowState.codeVerifier,
|
|
80
|
+
clientSecret
|
|
81
|
+
});
|
|
82
|
+
const tenantId = config.tenantId ?? "";
|
|
83
|
+
const { user } = await this.accountLinkingService.resolveUser(config, idpPayload, tenantId);
|
|
84
|
+
await this.rbacService.invalidateUserCache(String(user.id));
|
|
85
|
+
const roles = await this.authService.getUserRoles(user, tenantId || null);
|
|
86
|
+
const token = signJwt({
|
|
87
|
+
sub: String(user.id),
|
|
88
|
+
tenantId: tenantId || null,
|
|
89
|
+
orgId: user.organizationId ? String(user.organizationId) : null,
|
|
90
|
+
email: user.email,
|
|
91
|
+
roles
|
|
92
|
+
});
|
|
93
|
+
await this.authService.updateLastLoginAt(user);
|
|
94
|
+
const days = Number(process.env.REMEMBER_ME_DAYS || "30");
|
|
95
|
+
const sessionExpiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1e3);
|
|
96
|
+
const session = await this.authService.createSession(user, sessionExpiresAt);
|
|
97
|
+
void emitSsoEvent("sso.login.completed", {
|
|
98
|
+
id: String(user.id),
|
|
99
|
+
tenantId: config.tenantId,
|
|
100
|
+
organizationId: config.organizationId
|
|
101
|
+
}).catch((e) => console.error("[SSO Event]", e));
|
|
102
|
+
return {
|
|
103
|
+
token,
|
|
104
|
+
sessionToken: session.token,
|
|
105
|
+
sessionExpiresAt,
|
|
106
|
+
redirectUrl: flowState.returnUrl || "/backend",
|
|
107
|
+
tenantId: config.tenantId ?? null,
|
|
108
|
+
organizationId: config.organizationId
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async decryptClientSecret(config) {
|
|
112
|
+
if (!config.clientSecretEnc) return void 0;
|
|
113
|
+
const decrypted = await this.tenantEncryptionService.decryptEntityPayload(
|
|
114
|
+
config.id,
|
|
115
|
+
{ clientSecretEnc: config.clientSecretEnc },
|
|
116
|
+
config.tenantId,
|
|
117
|
+
config.organizationId
|
|
118
|
+
);
|
|
119
|
+
return decrypted.clientSecretEnc ?? void 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export {
|
|
123
|
+
SsoService
|
|
124
|
+
};
|
|
125
|
+
//# sourceMappingURL=ssoService.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 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 const token = signJwt({\n sub: String(user.id),\n tenantId: tenantId || null,\n orgId: user.organizationId ? String(user.organizationId) : null,\n email: user.email,\n roles,\n })\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 = await this.authService.createSession(user, sessionExpiresAt)\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: session.token,\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
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SsoConfig } from "./data/entities.js";
|
|
2
|
+
const setup = {
|
|
3
|
+
defaultRoleFeatures: {
|
|
4
|
+
superadmin: ["sso.*"],
|
|
5
|
+
admin: ["sso.config.view", "sso.config.manage", "sso.scim.manage"]
|
|
6
|
+
},
|
|
7
|
+
async seedDefaults({ em, tenantId, organizationId, container }) {
|
|
8
|
+
if (process.env.NODE_ENV !== "development") return;
|
|
9
|
+
if (process.env.SSO_DEV_SEED !== "true") return;
|
|
10
|
+
const clientSecret = process.env.SSO_DEV_CLIENT_SECRET;
|
|
11
|
+
if (!clientSecret) return;
|
|
12
|
+
const domains = (process.env.SSO_DEV_ALLOWED_DOMAINS || "example.com").split(",").map((d) => d.trim()).filter(Boolean);
|
|
13
|
+
const existing = await em.findOne(SsoConfig, { organizationId });
|
|
14
|
+
if (existing) {
|
|
15
|
+
existing.allowedDomains = domains;
|
|
16
|
+
await em.flush();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const encryptionService = container.resolve("tenantEncryptionService");
|
|
20
|
+
const encrypted = await encryptionService.encryptEntityPayload(
|
|
21
|
+
"SsoConfig",
|
|
22
|
+
{ clientSecretEnc: clientSecret },
|
|
23
|
+
tenantId,
|
|
24
|
+
organizationId
|
|
25
|
+
);
|
|
26
|
+
const config = em.create(SsoConfig, {
|
|
27
|
+
tenantId,
|
|
28
|
+
organizationId,
|
|
29
|
+
protocol: "oidc",
|
|
30
|
+
issuer: process.env.SSO_DEV_ISSUER || "http://localhost:8080/realms/open-mercato",
|
|
31
|
+
clientId: process.env.SSO_DEV_CLIENT_ID || "open-mercato-app",
|
|
32
|
+
clientSecretEnc: encrypted.clientSecretEnc,
|
|
33
|
+
allowedDomains: domains,
|
|
34
|
+
jitEnabled: true,
|
|
35
|
+
autoLinkByEmail: true,
|
|
36
|
+
isActive: true,
|
|
37
|
+
ssoRequired: false
|
|
38
|
+
});
|
|
39
|
+
await em.persistAndFlush(config);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var setup_default = setup;
|
|
43
|
+
export {
|
|
44
|
+
setup_default as default,
|
|
45
|
+
setup
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=setup.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/sso/setup.ts"],
|
|
4
|
+
"sourcesContent": ["import type { RequiredEntityData } from '@mikro-orm/core'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { SsoConfig } from './data/entities'\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n superadmin: ['sso.*'],\n admin: ['sso.config.view', 'sso.config.manage', 'sso.scim.manage'],\n },\n\n async seedDefaults({ em, tenantId, organizationId, container }) {\n if (process.env.NODE_ENV !== 'development') return\n if (process.env.SSO_DEV_SEED !== 'true') return\n\n const clientSecret = process.env.SSO_DEV_CLIENT_SECRET\n if (!clientSecret) return\n\n const domains = (process.env.SSO_DEV_ALLOWED_DOMAINS || 'example.com')\n .split(',')\n .map((d) => d.trim())\n .filter(Boolean)\n\n const existing = await em.findOne(SsoConfig, { organizationId })\n if (existing) {\n existing.allowedDomains = domains\n await em.flush()\n return\n }\n\n const encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')\n const encrypted = await encryptionService.encryptEntityPayload(\n 'SsoConfig',\n { clientSecretEnc: clientSecret },\n tenantId,\n organizationId,\n )\n\n const config = em.create(SsoConfig, {\n tenantId,\n organizationId,\n protocol: 'oidc',\n issuer: process.env.SSO_DEV_ISSUER || 'http://localhost:8080/realms/open-mercato',\n clientId: process.env.SSO_DEV_CLIENT_ID || 'open-mercato-app',\n clientSecretEnc: encrypted.clientSecretEnc as string,\n allowedDomains: domains,\n jitEnabled: true,\n autoLinkByEmail: true,\n isActive: true,\n ssoRequired: false,\n } as RequiredEntityData<SsoConfig>)\n await em.persistAndFlush(config)\n },\n}\n\nexport default setup\n"],
|
|
5
|
+
"mappings": "AAGA,SAAS,iBAAiB;AAEnB,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,YAAY,CAAC,OAAO;AAAA,IACpB,OAAO,CAAC,mBAAmB,qBAAqB,iBAAiB;AAAA,EACnE;AAAA,EAEA,MAAM,aAAa,EAAE,IAAI,UAAU,gBAAgB,UAAU,GAAG;AAC9D,QAAI,QAAQ,IAAI,aAAa,cAAe;AAC5C,QAAI,QAAQ,IAAI,iBAAiB,OAAQ;AAEzC,UAAM,eAAe,QAAQ,IAAI;AACjC,QAAI,CAAC,aAAc;AAEnB,UAAM,WAAW,QAAQ,IAAI,2BAA2B,eACrD,MAAM,GAAG,EACT,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EACnB,OAAO,OAAO;AAEjB,UAAM,WAAW,MAAM,GAAG,QAAQ,WAAW,EAAE,eAAe,CAAC;AAC/D,QAAI,UAAU;AACZ,eAAS,iBAAiB;AAC1B,YAAM,GAAG,MAAM;AACf;AAAA,IACF;AAEA,UAAM,oBAAoB,UAAU,QAAqC,yBAAyB;AAClG,UAAM,YAAY,MAAM,kBAAkB;AAAA,MACxC;AAAA,MACA,EAAE,iBAAiB,aAAa;AAAA,MAChC;AAAA,MACA;AAAA,IACF;AAEA,UAAM,SAAS,GAAG,OAAO,WAAW;AAAA,MAClC;AAAA,MACA;AAAA,MACA,UAAU;AAAA,MACV,QAAQ,QAAQ,IAAI,kBAAkB;AAAA,MACtC,UAAU,QAAQ,IAAI,qBAAqB;AAAA,MAC3C,iBAAiB,UAAU;AAAA,MAC3B,gBAAgB;AAAA,MAChB,YAAY;AAAA,MACZ,iBAAiB;AAAA,MACjB,UAAU;AAAA,MACV,aAAa;AAAA,IACf,CAAkC;AAClC,UAAM,GAAG,gBAAgB,MAAM;AAAA,EACjC;AACF;AAEA,IAAO,gBAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { SsoIdentity, SsoRoleGrant, SsoUserDeactivation } from "../data/entities.js";
|
|
2
|
+
const metadata = {
|
|
3
|
+
event: "auth.user.deleted",
|
|
4
|
+
persistent: true,
|
|
5
|
+
id: "sso:user-deleted-cleanup"
|
|
6
|
+
};
|
|
7
|
+
async function handle(payload, ctx) {
|
|
8
|
+
const em = ctx.resolve("em");
|
|
9
|
+
await em.nativeUpdate(
|
|
10
|
+
SsoIdentity,
|
|
11
|
+
{ userId: payload.userId, deletedAt: null },
|
|
12
|
+
{ deletedAt: /* @__PURE__ */ new Date() }
|
|
13
|
+
);
|
|
14
|
+
await em.nativeDelete(SsoRoleGrant, { userId: payload.userId });
|
|
15
|
+
await em.nativeDelete(SsoUserDeactivation, { userId: payload.userId });
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
handle as default,
|
|
19
|
+
metadata
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=user-deleted-cleanup.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/sso/subscribers/user-deleted-cleanup.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityData } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { SsoIdentity, SsoRoleGrant, SsoUserDeactivation } from '../data/entities'\n\nexport const metadata = {\n event: 'auth.user.deleted',\n persistent: true,\n id: 'sso:user-deleted-cleanup',\n}\n\ntype UserDeletedPayload = {\n userId: string\n tenantId: string\n organizationId: string\n}\n\ntype ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport default async function handle(payload: UserDeletedPayload, ctx: ResolverContext) {\n const em = ctx.resolve<EntityManager>('em')\n\n await em.nativeUpdate(\n SsoIdentity,\n { userId: payload.userId, deletedAt: null },\n { deletedAt: new Date() } as EntityData<SsoIdentity>,\n )\n\n await em.nativeDelete(SsoRoleGrant, { userId: payload.userId })\n\n await em.nativeDelete(SsoUserDeactivation, { userId: payload.userId })\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,aAAa,cAAc,2BAA2B;AAExD,MAAM,WAAW;AAAA,EACtB,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,IAAI;AACN;AAYA,eAAO,OAA8B,SAA6B,KAAsB;AACtF,QAAM,KAAK,IAAI,QAAuB,IAAI;AAE1C,QAAM,GAAG;AAAA,IACP;AAAA,IACA,EAAE,QAAQ,QAAQ,QAAQ,WAAW,KAAK;AAAA,IAC1C,EAAE,WAAW,oBAAI,KAAK,EAAE;AAAA,EAC1B;AAEA,QAAM,GAAG,aAAa,cAAc,EAAE,QAAQ,QAAQ,OAAO,CAAC;AAE9D,QAAM,GAAG,aAAa,qBAAqB,EAAE,QAAQ,QAAQ,OAAO,CAAC;AACvE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
|
|
5
|
+
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
|
+
import { translateWithFallback } from "@open-mercato/shared/lib/i18n/translate";
|
|
7
|
+
const SSO_ERROR_CODES = [
|
|
8
|
+
"sso_failed",
|
|
9
|
+
"sso_missing_config",
|
|
10
|
+
"sso_email_not_verified",
|
|
11
|
+
"sso_state_missing",
|
|
12
|
+
"sso_idp_error",
|
|
13
|
+
"sso_missing_params"
|
|
14
|
+
];
|
|
15
|
+
const HRD_DEBOUNCE_MS = 300;
|
|
16
|
+
function SsoLoginWidget({ context }) {
|
|
17
|
+
const t = useT();
|
|
18
|
+
const translate = useCallback(
|
|
19
|
+
(key, fallback) => translateWithFallback(t, key, fallback),
|
|
20
|
+
[t]
|
|
21
|
+
);
|
|
22
|
+
const [ssoActive, setSsoActive] = useState(false);
|
|
23
|
+
const lastCheckedEmail = useRef("");
|
|
24
|
+
const debounceTimer = useRef(null);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const errorParam = context.searchParams.get("error");
|
|
27
|
+
if (!errorParam) return;
|
|
28
|
+
const errorMessages = {
|
|
29
|
+
sso_failed: translate("sso.login.errors.failed", "SSO login failed. Please try again."),
|
|
30
|
+
sso_missing_config: translate("sso.login.errors.missingConfig", "SSO is not configured for this account."),
|
|
31
|
+
sso_email_not_verified: translate("sso.login.errors.emailNotVerified", "Your email address is not verified by the identity provider. Please verify your email and try again."),
|
|
32
|
+
sso_state_missing: translate("sso.login.errors.stateMissing", "SSO session expired. Please try again."),
|
|
33
|
+
sso_idp_error: translate("sso.login.errors.idpError", "The identity provider returned an error. Please try again or contact your administrator."),
|
|
34
|
+
sso_missing_params: translate("sso.login.errors.missingParams", "SSO callback was incomplete. Please try again.")
|
|
35
|
+
};
|
|
36
|
+
if (SSO_ERROR_CODES.includes(errorParam)) {
|
|
37
|
+
context.setError(errorMessages[errorParam] ?? errorMessages.sso_failed);
|
|
38
|
+
}
|
|
39
|
+
}, [context.searchParams, context.setError, translate]);
|
|
40
|
+
const checkHrd = useCallback(async (email) => {
|
|
41
|
+
if (!email || !email.includes("@")) {
|
|
42
|
+
context.setAuthOverride(null);
|
|
43
|
+
setSsoActive(false);
|
|
44
|
+
lastCheckedEmail.current = "";
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (email === lastCheckedEmail.current) return;
|
|
48
|
+
lastCheckedEmail.current = email;
|
|
49
|
+
try {
|
|
50
|
+
const body = { email };
|
|
51
|
+
if (context.tenantId) {
|
|
52
|
+
body.tenantId = context.tenantId;
|
|
53
|
+
}
|
|
54
|
+
const res = await apiCall(
|
|
55
|
+
"/api/sso/hrd",
|
|
56
|
+
{ method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" } }
|
|
57
|
+
);
|
|
58
|
+
if (res.result?.hasSso && res.result.configId) {
|
|
59
|
+
const configId = res.result.configId;
|
|
60
|
+
setSsoActive(true);
|
|
61
|
+
context.setAuthOverride({
|
|
62
|
+
providerId: "sso",
|
|
63
|
+
providerLabel: translate("sso.login.continueWithSso", "Continue with SSO"),
|
|
64
|
+
onSubmit: () => {
|
|
65
|
+
const returnUrl = context.searchParams.get("returnUrl") || "/backend";
|
|
66
|
+
window.location.href = `/api/sso/initiate?configId=${encodeURIComponent(configId)}&returnUrl=${encodeURIComponent(returnUrl)}`;
|
|
67
|
+
},
|
|
68
|
+
hidePassword: true,
|
|
69
|
+
hideRememberMe: true,
|
|
70
|
+
hideForgotPassword: true
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
setSsoActive(false);
|
|
74
|
+
context.setAuthOverride(null);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
setSsoActive(false);
|
|
78
|
+
context.setAuthOverride(null);
|
|
79
|
+
}
|
|
80
|
+
}, [context, translate]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (debounceTimer.current) {
|
|
83
|
+
clearTimeout(debounceTimer.current);
|
|
84
|
+
}
|
|
85
|
+
if (!context.email) {
|
|
86
|
+
setSsoActive(false);
|
|
87
|
+
context.setAuthOverride(null);
|
|
88
|
+
lastCheckedEmail.current = "";
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
debounceTimer.current = setTimeout(() => {
|
|
92
|
+
checkHrd(context.email);
|
|
93
|
+
}, HRD_DEBOUNCE_MS);
|
|
94
|
+
return () => {
|
|
95
|
+
if (debounceTimer.current) {
|
|
96
|
+
clearTimeout(debounceTimer.current);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}, [context.email, checkHrd]);
|
|
100
|
+
if (!ssoActive) return null;
|
|
101
|
+
return /* @__PURE__ */ jsx("div", { className: "rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-center text-xs text-blue-800", children: translate("sso.login.ssoEnabled", "SSO is enabled for this account") });
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
SsoLoginWidget as default
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=widget.client.js.map
|