@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.
Files changed (195) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +2 -2
  3. package/dist/modules/sso/acl.js +11 -0
  4. package/dist/modules/sso/acl.js.map +7 -0
  5. package/dist/modules/sso/api/admin-context.js +27 -0
  6. package/dist/modules/sso/api/admin-context.js.map +7 -0
  7. package/dist/modules/sso/api/callback/oidc/route.js +103 -0
  8. package/dist/modules/sso/api/callback/oidc/route.js.map +7 -0
  9. package/dist/modules/sso/api/config/[id]/activate/route.js +49 -0
  10. package/dist/modules/sso/api/config/[id]/activate/route.js.map +7 -0
  11. package/dist/modules/sso/api/config/[id]/domains/route.js +96 -0
  12. package/dist/modules/sso/api/config/[id]/domains/route.js.map +7 -0
  13. package/dist/modules/sso/api/config/[id]/route.js +103 -0
  14. package/dist/modules/sso/api/config/[id]/route.js.map +7 -0
  15. package/dist/modules/sso/api/config/[id]/test/route.js +41 -0
  16. package/dist/modules/sso/api/config/[id]/test/route.js.map +7 -0
  17. package/dist/modules/sso/api/config/route.js +83 -0
  18. package/dist/modules/sso/api/config/route.js.map +7 -0
  19. package/dist/modules/sso/api/error-handler.js +28 -0
  20. package/dist/modules/sso/api/error-handler.js.map +7 -0
  21. package/dist/modules/sso/api/hrd/route.js +52 -0
  22. package/dist/modules/sso/api/hrd/route.js.map +7 -0
  23. package/dist/modules/sso/api/initiate/route.js +66 -0
  24. package/dist/modules/sso/api/initiate/route.js.map +7 -0
  25. package/dist/modules/sso/api/scim/context.js +68 -0
  26. package/dist/modules/sso/api/scim/context.js.map +7 -0
  27. package/dist/modules/sso/api/scim/logs/route.js +65 -0
  28. package/dist/modules/sso/api/scim/logs/route.js.map +7 -0
  29. package/dist/modules/sso/api/scim/tokens/[id]/route.js +42 -0
  30. package/dist/modules/sso/api/scim/tokens/[id]/route.js.map +7 -0
  31. package/dist/modules/sso/api/scim/tokens/route.js +83 -0
  32. package/dist/modules/sso/api/scim/tokens/route.js.map +7 -0
  33. package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js +42 -0
  34. package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js.map +7 -0
  35. package/dist/modules/sso/api/scim/v2/Users/[id]/route.js +94 -0
  36. package/dist/modules/sso/api/scim/v2/Users/[id]/route.js.map +7 -0
  37. package/dist/modules/sso/api/scim/v2/Users/route.js +86 -0
  38. package/dist/modules/sso/api/scim/v2/Users/route.js.map +7 -0
  39. package/dist/modules/sso/backend/page.js +173 -0
  40. package/dist/modules/sso/backend/page.js.map +7 -0
  41. package/dist/modules/sso/backend/page.meta.js +31 -0
  42. package/dist/modules/sso/backend/page.meta.js.map +7 -0
  43. package/dist/modules/sso/backend/sso/config/[id]/page.js +749 -0
  44. package/dist/modules/sso/backend/sso/config/[id]/page.js.map +7 -0
  45. package/dist/modules/sso/backend/sso/config/[id]/page.meta.js +19 -0
  46. package/dist/modules/sso/backend/sso/config/[id]/page.meta.js.map +7 -0
  47. package/dist/modules/sso/backend/sso/config/new/page.js +381 -0
  48. package/dist/modules/sso/backend/sso/config/new/page.js.map +7 -0
  49. package/dist/modules/sso/backend/sso/config/new/page.meta.js +19 -0
  50. package/dist/modules/sso/backend/sso/config/new/page.meta.js.map +7 -0
  51. package/dist/modules/sso/data/entities.js +299 -0
  52. package/dist/modules/sso/data/entities.js.map +7 -0
  53. package/dist/modules/sso/data/validators.js +114 -0
  54. package/dist/modules/sso/data/validators.js.map +7 -0
  55. package/dist/modules/sso/di.js +26 -0
  56. package/dist/modules/sso/di.js.map +7 -0
  57. package/dist/modules/sso/events.js +24 -0
  58. package/dist/modules/sso/events.js.map +7 -0
  59. package/dist/modules/sso/i18n/de.json +146 -0
  60. package/dist/modules/sso/i18n/en.json +146 -0
  61. package/dist/modules/sso/i18n/es.json +146 -0
  62. package/dist/modules/sso/i18n/pl.json +146 -0
  63. package/dist/modules/sso/index.js +11 -0
  64. package/dist/modules/sso/index.js.map +7 -0
  65. package/dist/modules/sso/lib/domains.js +30 -0
  66. package/dist/modules/sso/lib/domains.js.map +7 -0
  67. package/dist/modules/sso/lib/oidc-provider.js +140 -0
  68. package/dist/modules/sso/lib/oidc-provider.js.map +7 -0
  69. package/dist/modules/sso/lib/registry.js +15 -0
  70. package/dist/modules/sso/lib/registry.js.map +7 -0
  71. package/dist/modules/sso/lib/scim-filter.js +43 -0
  72. package/dist/modules/sso/lib/scim-filter.js.map +7 -0
  73. package/dist/modules/sso/lib/scim-mapper.js +49 -0
  74. package/dist/modules/sso/lib/scim-mapper.js.map +7 -0
  75. package/dist/modules/sso/lib/scim-patch.js +63 -0
  76. package/dist/modules/sso/lib/scim-patch.js.map +7 -0
  77. package/dist/modules/sso/lib/scim-response.js +34 -0
  78. package/dist/modules/sso/lib/scim-response.js.map +7 -0
  79. package/dist/modules/sso/lib/scim-utils.js +9 -0
  80. package/dist/modules/sso/lib/scim-utils.js.map +7 -0
  81. package/dist/modules/sso/lib/state-cookie.js +67 -0
  82. package/dist/modules/sso/lib/state-cookie.js.map +7 -0
  83. package/dist/modules/sso/lib/types.js +1 -0
  84. package/dist/modules/sso/lib/types.js.map +7 -0
  85. package/dist/modules/sso/migrations/Migration20260219000000_sso.js +20 -0
  86. package/dist/modules/sso/migrations/Migration20260219000000_sso.js.map +7 -0
  87. package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js +13 -0
  88. package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js.map +7 -0
  89. package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js +15 -0
  90. package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js.map +7 -0
  91. package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js +22 -0
  92. package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js.map +7 -0
  93. package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js +15 -0
  94. package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js.map +7 -0
  95. package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js +17 -0
  96. package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js.map +7 -0
  97. package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js +13 -0
  98. package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js.map +7 -0
  99. package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js +23 -0
  100. package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js.map +7 -0
  101. package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js +14 -0
  102. package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js.map +7 -0
  103. package/dist/modules/sso/services/accountLinkingService.js +298 -0
  104. package/dist/modules/sso/services/accountLinkingService.js.map +7 -0
  105. package/dist/modules/sso/services/hrdService.js +18 -0
  106. package/dist/modules/sso/services/hrdService.js.map +7 -0
  107. package/dist/modules/sso/services/scimService.js +372 -0
  108. package/dist/modules/sso/services/scimService.js.map +7 -0
  109. package/dist/modules/sso/services/scimTokenService.js +94 -0
  110. package/dist/modules/sso/services/scimTokenService.js.map +7 -0
  111. package/dist/modules/sso/services/ssoConfigService.js +254 -0
  112. package/dist/modules/sso/services/ssoConfigService.js.map +7 -0
  113. package/dist/modules/sso/services/ssoService.js +125 -0
  114. package/dist/modules/sso/services/ssoService.js.map +7 -0
  115. package/dist/modules/sso/setup.js +47 -0
  116. package/dist/modules/sso/setup.js.map +7 -0
  117. package/dist/modules/sso/subscribers/user-deleted-cleanup.js +21 -0
  118. package/dist/modules/sso/subscribers/user-deleted-cleanup.js.map +7 -0
  119. package/dist/modules/sso/widgets/injection/login-sso/widget.client.js +106 -0
  120. package/dist/modules/sso/widgets/injection/login-sso/widget.client.js.map +7 -0
  121. package/dist/modules/sso/widgets/injection/login-sso/widget.js +16 -0
  122. package/dist/modules/sso/widgets/injection/login-sso/widget.js.map +7 -0
  123. package/dist/modules/sso/widgets/injection-table.js +14 -0
  124. package/dist/modules/sso/widgets/injection-table.js.map +7 -0
  125. package/package.json +5 -4
  126. package/src/index.ts +1 -1
  127. package/src/modules/sso/acl.ts +7 -0
  128. package/src/modules/sso/api/admin-context.ts +36 -0
  129. package/src/modules/sso/api/callback/oidc/route.ts +115 -0
  130. package/src/modules/sso/api/config/[id]/activate/route.ts +53 -0
  131. package/src/modules/sso/api/config/[id]/domains/route.ts +107 -0
  132. package/src/modules/sso/api/config/[id]/route.ts +114 -0
  133. package/src/modules/sso/api/config/[id]/test/route.ts +44 -0
  134. package/src/modules/sso/api/config/route.ts +88 -0
  135. package/src/modules/sso/api/error-handler.ts +36 -0
  136. package/src/modules/sso/api/hrd/route.ts +55 -0
  137. package/src/modules/sso/api/initiate/route.ts +70 -0
  138. package/src/modules/sso/api/scim/context.ts +85 -0
  139. package/src/modules/sso/api/scim/logs/route.ts +69 -0
  140. package/src/modules/sso/api/scim/tokens/[id]/route.ts +45 -0
  141. package/src/modules/sso/api/scim/tokens/route.ts +89 -0
  142. package/src/modules/sso/api/scim/v2/ServiceProviderConfig/route.ts +40 -0
  143. package/src/modules/sso/api/scim/v2/Users/[id]/route.ts +103 -0
  144. package/src/modules/sso/api/scim/v2/Users/route.ts +94 -0
  145. package/src/modules/sso/backend/page.meta.ts +29 -0
  146. package/src/modules/sso/backend/page.tsx +232 -0
  147. package/src/modules/sso/backend/sso/config/[id]/page.meta.ts +15 -0
  148. package/src/modules/sso/backend/sso/config/[id]/page.tsx +1024 -0
  149. package/src/modules/sso/backend/sso/config/new/page.meta.ts +15 -0
  150. package/src/modules/sso/backend/sso/config/new/page.tsx +463 -0
  151. package/src/modules/sso/data/entities.ts +240 -0
  152. package/src/modules/sso/data/validators.ts +140 -0
  153. package/src/modules/sso/di.ts +25 -0
  154. package/src/modules/sso/docs/entra-id-setup.md +281 -0
  155. package/src/modules/sso/docs/google-workspace-setup.md +174 -0
  156. package/src/modules/sso/docs/sso-overview.md +218 -0
  157. package/src/modules/sso/docs/sso-security-audit-2026-02-27.md +118 -0
  158. package/src/modules/sso/docs/zitadel-setup.md +195 -0
  159. package/src/modules/sso/events.ts +21 -0
  160. package/src/modules/sso/i18n/de.json +146 -0
  161. package/src/modules/sso/i18n/en.json +146 -0
  162. package/src/modules/sso/i18n/es.json +146 -0
  163. package/src/modules/sso/i18n/pl.json +146 -0
  164. package/src/modules/sso/index.ts +7 -0
  165. package/src/modules/sso/lib/domains.ts +31 -0
  166. package/src/modules/sso/lib/oidc-provider.ts +196 -0
  167. package/src/modules/sso/lib/registry.ts +13 -0
  168. package/src/modules/sso/lib/scim-filter.ts +62 -0
  169. package/src/modules/sso/lib/scim-mapper.ts +88 -0
  170. package/src/modules/sso/lib/scim-patch.ts +88 -0
  171. package/src/modules/sso/lib/scim-response.ts +40 -0
  172. package/src/modules/sso/lib/scim-utils.ts +5 -0
  173. package/src/modules/sso/lib/state-cookie.ts +79 -0
  174. package/src/modules/sso/lib/types.ts +50 -0
  175. package/src/modules/sso/migrations/.snapshot-open-mercato.json +912 -0
  176. package/src/modules/sso/migrations/Migration20260219000000_sso.ts +21 -0
  177. package/src/modules/sso/migrations/Migration20260222000000_sso_add_name.ts +13 -0
  178. package/src/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.ts +15 -0
  179. package/src/modules/sso/migrations/Migration20260223000000_scim_tables.ts +24 -0
  180. package/src/modules/sso/migrations/Migration20260224000000_sso_external_id.ts +15 -0
  181. package/src/modules/sso/migrations/Migration20260224100000_sso_role_grants.ts +18 -0
  182. package/src/modules/sso/migrations/Migration20260224200000_drop_default_role_id.ts +13 -0
  183. package/src/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.ts +25 -0
  184. package/src/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.ts +14 -0
  185. package/src/modules/sso/services/accountLinkingService.ts +386 -0
  186. package/src/modules/sso/services/hrdService.ts +22 -0
  187. package/src/modules/sso/services/scimService.ts +461 -0
  188. package/src/modules/sso/services/scimTokenService.ts +136 -0
  189. package/src/modules/sso/services/ssoConfigService.ts +337 -0
  190. package/src/modules/sso/services/ssoService.ts +167 -0
  191. package/src/modules/sso/setup.ts +56 -0
  192. package/src/modules/sso/subscribers/user-deleted-cleanup.ts +33 -0
  193. package/src/modules/sso/widgets/injection/login-sso/widget.client.tsx +130 -0
  194. package/src/modules/sso/widgets/injection/login-sso/widget.ts +16 -0
  195. package/src/modules/sso/widgets/injection-table.ts +12 -0
@@ -0,0 +1,337 @@
1
+ import type { FilterQuery, RequiredEntityData } from '@mikro-orm/core'
2
+ import { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
5
+ import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
6
+ import { SsoConfig, ScimToken } from '../data/entities'
7
+ import type { SsoConfigAdminCreateInput, SsoConfigAdminUpdateInput, SsoConfigListQuery } from '../data/validators'
8
+ import { emitSsoEvent } from '../events'
9
+ import { validateDomain, normalizeDomain, uniqueDomains, checkDomainLimit } from '../lib/domains'
10
+ import type { SsoProviderRegistry } from '../lib/registry'
11
+
12
+ export interface SsoAdminScope {
13
+ isSuperAdmin: boolean
14
+ organizationId: string | null
15
+ tenantId: string | null
16
+ }
17
+
18
+ export interface SsoConfigPublic {
19
+ id: string
20
+ name: string | null
21
+ tenantId: string | null
22
+ organizationId: string
23
+ protocol: string
24
+ issuer: string | null
25
+ clientId: string | null
26
+ hasClientSecret: boolean
27
+ allowedDomains: string[]
28
+ jitEnabled: boolean
29
+ autoLinkByEmail: boolean
30
+ isActive: boolean
31
+ ssoRequired: boolean
32
+ appRoleMappings: Record<string, string>
33
+ createdAt: Date
34
+ updatedAt: Date
35
+ }
36
+
37
+ export class SsoConfigService {
38
+ constructor(
39
+ private em: EntityManager,
40
+ private tenantEncryptionService: TenantDataEncryptionService,
41
+ private ssoProviderRegistry: SsoProviderRegistry,
42
+ ) {}
43
+
44
+ async list(scope: SsoAdminScope, query: SsoConfigListQuery): Promise<{
45
+ items: SsoConfigPublic[]
46
+ total: number
47
+ totalPages: number
48
+ }> {
49
+ const where: FilterQuery<SsoConfig> = { deletedAt: null }
50
+
51
+ if (!scope.isSuperAdmin) {
52
+ if (!scope.organizationId) {
53
+ throw new SsoConfigError('Organization context is required', 403)
54
+ }
55
+ where.organizationId = scope.organizationId
56
+ } else {
57
+ if (query.organizationId) where.organizationId = query.organizationId
58
+ if (query.tenantId) where.tenantId = query.tenantId
59
+ }
60
+
61
+ if (query.search) {
62
+ const pattern = `%${escapeLikePattern(query.search)}%`
63
+ where.$or = [
64
+ { name: { $ilike: pattern } },
65
+ { issuer: { $ilike: pattern } },
66
+ { clientId: { $ilike: pattern } },
67
+ ]
68
+ }
69
+
70
+ const [configs, total] = await this.em.findAndCount(SsoConfig, where, {
71
+ orderBy: { createdAt: 'desc' },
72
+ limit: query.pageSize,
73
+ offset: (query.page - 1) * query.pageSize,
74
+ })
75
+
76
+ return {
77
+ items: configs.map((c) => this.toPublic(c)),
78
+ total,
79
+ totalPages: Math.ceil(total / query.pageSize) || 1,
80
+ }
81
+ }
82
+
83
+ async getById(scope: SsoAdminScope, id: string): Promise<SsoConfigPublic | null> {
84
+ const where: FilterQuery<SsoConfig> = { id, deletedAt: null }
85
+ if (!scope.isSuperAdmin) {
86
+ if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)
87
+ where.organizationId = scope.organizationId
88
+ }
89
+
90
+ const config = await this.em.findOne(SsoConfig, where)
91
+ return config ? this.toPublic(config) : null
92
+ }
93
+
94
+ async create(scope: SsoAdminScope, input: SsoConfigAdminCreateInput): Promise<SsoConfigPublic> {
95
+ const orgId = scope.isSuperAdmin ? input.organizationId : scope.organizationId!
96
+ const tenId = scope.isSuperAdmin ? (input.tenantId ?? null) : scope.tenantId
97
+
98
+ const existing = await this.em.findOne(SsoConfig, {
99
+ organizationId: orgId,
100
+ deletedAt: null,
101
+ })
102
+ if (existing) {
103
+ throw new SsoConfigError('An SSO configuration already exists for this organization', 409)
104
+ }
105
+
106
+ const domains = uniqueDomains(input.allowedDomains)
107
+ for (const d of domains) {
108
+ const result = validateDomain(d)
109
+ if (!result.valid) throw new SsoConfigError(`Invalid domain "${d}": ${result.error}`, 400)
110
+ }
111
+
112
+ const encrypted = await this.tenantEncryptionService.encryptEntityPayload(
113
+ 'SsoConfig',
114
+ { clientSecretEnc: input.clientSecret },
115
+ tenId,
116
+ orgId,
117
+ )
118
+
119
+ const config = this.em.create(SsoConfig, {
120
+ name: input.name,
121
+ tenantId: tenId,
122
+ organizationId: orgId,
123
+ protocol: input.protocol,
124
+ issuer: input.issuer,
125
+ clientId: input.clientId,
126
+ clientSecretEnc: encrypted.clientSecretEnc as string,
127
+ allowedDomains: domains,
128
+ jitEnabled: input.jitEnabled,
129
+ autoLinkByEmail: input.autoLinkByEmail,
130
+ isActive: false,
131
+ ssoRequired: false,
132
+ appRoleMappings: input.appRoleMappings ?? {},
133
+ } as RequiredEntityData<SsoConfig>)
134
+
135
+ await this.em.persistAndFlush(config)
136
+
137
+ void emitSsoEvent('sso.config.created', {
138
+ id: config.id,
139
+ tenantId: config.tenantId,
140
+ organizationId: config.organizationId,
141
+ }).catch((e) => console.error('[SSO Event]', e))
142
+
143
+ return this.toPublic(config)
144
+ }
145
+
146
+ async update(scope: SsoAdminScope, id: string, input: SsoConfigAdminUpdateInput): Promise<SsoConfigPublic> {
147
+ const config = await this.resolveConfig(scope, id)
148
+
149
+ if (input.name !== undefined) config.name = input.name
150
+ if (input.protocol !== undefined) config.protocol = input.protocol
151
+ if (input.issuer !== undefined) config.issuer = input.issuer
152
+ if (input.clientId !== undefined) config.clientId = input.clientId
153
+ if (input.jitEnabled !== undefined) {
154
+ if (input.jitEnabled) {
155
+ const activeScimCount = await this.em.count(ScimToken, { ssoConfigId: id, isActive: true })
156
+ if (activeScimCount > 0) {
157
+ throw new SsoConfigError('Cannot enable JIT provisioning while SCIM directory sync is active. Revoke all SCIM tokens first.', 409)
158
+ }
159
+ }
160
+ config.jitEnabled = input.jitEnabled
161
+ }
162
+ if (input.autoLinkByEmail !== undefined) config.autoLinkByEmail = input.autoLinkByEmail
163
+ if (input.appRoleMappings !== undefined) config.appRoleMappings = input.appRoleMappings
164
+
165
+ if (input.clientSecret !== undefined) {
166
+ const encrypted = await this.tenantEncryptionService.encryptEntityPayload(
167
+ 'SsoConfig',
168
+ { clientSecretEnc: input.clientSecret },
169
+ config.tenantId,
170
+ config.organizationId,
171
+ )
172
+ config.clientSecretEnc = encrypted.clientSecretEnc as string
173
+ }
174
+
175
+ await this.em.flush()
176
+
177
+ void emitSsoEvent('sso.config.updated', {
178
+ id: config.id,
179
+ tenantId: config.tenantId,
180
+ organizationId: config.organizationId,
181
+ }).catch((e) => console.error('[SSO Event]', e))
182
+
183
+ return this.toPublic(config)
184
+ }
185
+
186
+ async delete(scope: SsoAdminScope, id: string): Promise<void> {
187
+ const config = await this.resolveConfig(scope, id)
188
+
189
+ if (config.isActive) {
190
+ throw new SsoConfigError('Cannot delete an active SSO configuration — deactivate it first', 400)
191
+ }
192
+
193
+ config.deletedAt = new Date()
194
+ await this.em.flush()
195
+
196
+ void emitSsoEvent('sso.config.deleted', {
197
+ id: config.id,
198
+ tenantId: config.tenantId,
199
+ organizationId: config.organizationId,
200
+ }).catch((e) => console.error('[SSO Event]', e))
201
+ }
202
+
203
+ async activate(scope: SsoAdminScope, id: string, active: boolean): Promise<SsoConfigPublic> {
204
+ const config = await this.resolveConfig(scope, id)
205
+
206
+ if (active) {
207
+ if (config.allowedDomains.length === 0) {
208
+ throw new SsoConfigError('Cannot activate SSO configuration with no allowed domains', 400)
209
+ }
210
+
211
+ const testResult = await this.testConnectionInternal(config)
212
+ if (!testResult.ok) {
213
+ throw new SsoConfigError(`Cannot activate — discovery failed: ${testResult.error}`, 400)
214
+ }
215
+ }
216
+
217
+ const wasActive = config.isActive
218
+ config.isActive = active
219
+ await this.em.flush()
220
+
221
+ if (active && !wasActive) {
222
+ void emitSsoEvent('sso.config.activated', {
223
+ id: config.id,
224
+ tenantId: config.tenantId,
225
+ organizationId: config.organizationId,
226
+ }).catch((e) => console.error('[SSO Event]', e))
227
+ } else if (!active && wasActive) {
228
+ void emitSsoEvent('sso.config.deactivated', {
229
+ id: config.id,
230
+ tenantId: config.tenantId,
231
+ organizationId: config.organizationId,
232
+ }).catch((e) => console.error('[SSO Event]', e))
233
+ }
234
+
235
+ return this.toPublic(config)
236
+ }
237
+
238
+ async testConnection(scope: SsoAdminScope, id: string): Promise<{ ok: boolean; error?: string }> {
239
+ const config = await this.resolveConfig(scope, id)
240
+ return this.testConnectionInternal(config)
241
+ }
242
+
243
+ async addDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {
244
+ const normalized = normalizeDomain(domain)
245
+ const validation = validateDomain(normalized)
246
+ if (!validation.valid) throw new SsoConfigError(validation.error!, 400)
247
+
248
+ const config = await this.resolveConfig(scope, id)
249
+
250
+ if (config.allowedDomains.includes(normalized)) {
251
+ return this.toPublic(config)
252
+ }
253
+
254
+ const limitCheck = checkDomainLimit(config.allowedDomains.length, 1)
255
+ if (!limitCheck.ok) throw new SsoConfigError(limitCheck.error!, 400)
256
+
257
+ config.allowedDomains = [...config.allowedDomains, normalized]
258
+ await this.em.flush()
259
+
260
+ void emitSsoEvent('sso.domain.added', {
261
+ id: config.id,
262
+ tenantId: config.tenantId,
263
+ organizationId: config.organizationId,
264
+ domain: normalized,
265
+ }).catch((e) => console.error('[SSO Event]', e))
266
+
267
+ return this.toPublic(config)
268
+ }
269
+
270
+ async removeDomain(scope: SsoAdminScope, id: string, domain: string): Promise<SsoConfigPublic> {
271
+ const normalized = normalizeDomain(domain)
272
+ const config = await this.resolveConfig(scope, id)
273
+
274
+ config.allowedDomains = config.allowedDomains.filter((d) => d !== normalized)
275
+ await this.em.flush()
276
+
277
+ void emitSsoEvent('sso.domain.removed', {
278
+ id: config.id,
279
+ tenantId: config.tenantId,
280
+ organizationId: config.organizationId,
281
+ domain: normalized,
282
+ }).catch((e) => console.error('[SSO Event]', e))
283
+
284
+ return this.toPublic(config)
285
+ }
286
+
287
+ toPublic(config: SsoConfig): SsoConfigPublic {
288
+ return {
289
+ id: config.id,
290
+ name: config.name ?? null,
291
+ tenantId: config.tenantId ?? null,
292
+ organizationId: config.organizationId,
293
+ protocol: config.protocol,
294
+ issuer: config.issuer ?? null,
295
+ clientId: config.clientId ?? null,
296
+ hasClientSecret: !!config.clientSecretEnc,
297
+ allowedDomains: config.allowedDomains,
298
+ jitEnabled: config.jitEnabled,
299
+ autoLinkByEmail: config.autoLinkByEmail,
300
+ isActive: config.isActive,
301
+ ssoRequired: config.ssoRequired,
302
+ appRoleMappings: config.appRoleMappings ?? {},
303
+ createdAt: config.createdAt,
304
+ updatedAt: config.updatedAt,
305
+ }
306
+ }
307
+
308
+ private async resolveConfig(scope: SsoAdminScope, id: string): Promise<SsoConfig> {
309
+ const where: FilterQuery<SsoConfig> = { id, deletedAt: null }
310
+ if (!scope.isSuperAdmin) {
311
+ if (!scope.organizationId) throw new SsoConfigError('Organization context is required', 403)
312
+ where.organizationId = scope.organizationId
313
+ }
314
+
315
+ const config = await this.em.findOne(SsoConfig, where)
316
+ if (!config) throw new SsoConfigError('SSO configuration not found', 404)
317
+
318
+ return config
319
+ }
320
+
321
+ private async testConnectionInternal(config: SsoConfig): Promise<{ ok: boolean; error?: string }> {
322
+ const provider = this.ssoProviderRegistry.resolve(config.protocol)
323
+ if (!provider) return { ok: false, error: `No provider for protocol: ${config.protocol}` }
324
+
325
+ return provider.validateConfig(config)
326
+ }
327
+ }
328
+
329
+ export class SsoConfigError extends Error {
330
+ constructor(
331
+ message: string,
332
+ public readonly statusCode: number,
333
+ ) {
334
+ super(message)
335
+ this.name = 'SsoConfigError'
336
+ }
337
+ }
@@ -0,0 +1,167 @@
1
+ import crypto from 'node:crypto'
2
+ import { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { signJwt } from '@open-mercato/shared/lib/auth/jwt'
5
+ import type { AuthService } from '@open-mercato/core/modules/auth/services/authService'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import { SsoConfig } from '../data/entities'
8
+ import type { SsoProviderRegistry } from '../lib/registry'
9
+ import type { AccountLinkingService } from './accountLinkingService'
10
+ import { encryptStateCookie, decryptStateCookie, createFlowState } from '../lib/state-cookie'
11
+ import { emitSsoEvent } from '../events'
12
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
13
+
14
+ export class SsoService {
15
+ constructor(
16
+ private em: EntityManager,
17
+ private ssoProviderRegistry: SsoProviderRegistry,
18
+ private accountLinkingService: AccountLinkingService,
19
+ private tenantEncryptionService: TenantDataEncryptionService,
20
+ private authService: AuthService,
21
+ private rbacService: RbacService,
22
+ ) {}
23
+
24
+ async findConfigByEmail(email: string): Promise<SsoConfig | null> {
25
+ const domain = email.split('@')[1]?.toLowerCase()
26
+ if (!domain) return null
27
+
28
+ const configs = await findWithDecryption(
29
+ this.em,
30
+ SsoConfig,
31
+ { isActive: true, deletedAt: null },
32
+ {},
33
+ { tenantId: null },
34
+ )
35
+ return configs.find((c) => c.allowedDomains.some((d) => d.toLowerCase() === domain)) ?? null
36
+ }
37
+
38
+ async initiateLogin(
39
+ configId: string,
40
+ returnUrl: string,
41
+ redirectUri: string,
42
+ ): Promise<{ redirectUrl: string; stateCookie: string }> {
43
+ const config = await findOneWithDecryption(
44
+ this.em,
45
+ SsoConfig,
46
+ { id: configId, isActive: true, deletedAt: null },
47
+ {},
48
+ { tenantId: null },
49
+ )
50
+ if (!config) throw new Error('SSO configuration not found or inactive')
51
+
52
+ const provider = this.ssoProviderRegistry.resolve(config.protocol)
53
+ if (!provider) throw new Error(`No provider registered for protocol: ${config.protocol}`)
54
+
55
+ const clientSecret = await this.decryptClientSecret(config)
56
+
57
+ const { state } = createFlowState({ configId, returnUrl })
58
+
59
+ void emitSsoEvent('sso.login.initiated', {
60
+ tenantId: config.tenantId,
61
+ organizationId: config.organizationId,
62
+ }).catch((e) => console.error('[SSO Event]', e))
63
+
64
+ const authUrl = await provider.buildAuthUrl(config, {
65
+ state: state.state,
66
+ nonce: state.nonce,
67
+ redirectUri,
68
+ codeVerifier: state.codeVerifier,
69
+ clientSecret,
70
+ })
71
+
72
+ const stateCookie = encryptStateCookie(state)
73
+ return { redirectUrl: authUrl, stateCookie }
74
+ }
75
+
76
+ async handleOidcCallback(
77
+ callbackParams: Record<string, string>,
78
+ stateCookie: string,
79
+ redirectUri: string,
80
+ ): Promise<{
81
+ token: string
82
+ sessionToken: string
83
+ sessionExpiresAt: Date
84
+ redirectUrl: string
85
+ tenantId: string | null
86
+ organizationId: string
87
+ }> {
88
+ const flowState = decryptStateCookie(stateCookie)
89
+ if (!flowState) throw new Error('Invalid or expired SSO state')
90
+
91
+ const receivedState = Buffer.from(callbackParams.state || '')
92
+ const expectedState = Buffer.from(flowState.state)
93
+ if (receivedState.length !== expectedState.length || !crypto.timingSafeEqual(receivedState, expectedState)) {
94
+ throw new Error('State mismatch — possible CSRF attack')
95
+ }
96
+
97
+ const config = await findOneWithDecryption(
98
+ this.em,
99
+ SsoConfig,
100
+ { id: flowState.configId, isActive: true, deletedAt: null },
101
+ {},
102
+ { tenantId: null },
103
+ )
104
+ if (!config) throw new Error('SSO configuration no longer active')
105
+
106
+ const provider = this.ssoProviderRegistry.resolve(config.protocol)
107
+ if (!provider) throw new Error(`No provider for protocol: ${config.protocol}`)
108
+
109
+ const clientSecret = await this.decryptClientSecret(config)
110
+
111
+ const idpPayload = await provider.handleCallback(config, {
112
+ callbackParams,
113
+ redirectUri,
114
+ expectedState: flowState.state,
115
+ expectedNonce: flowState.nonce,
116
+ codeVerifier: flowState.codeVerifier,
117
+ clientSecret,
118
+ })
119
+
120
+ const tenantId = config.tenantId ?? ''
121
+ const { user } = await this.accountLinkingService.resolveUser(config, idpPayload, tenantId)
122
+
123
+ await this.rbacService.invalidateUserCache(String(user.id))
124
+
125
+ const roles = await this.authService.getUserRoles(user, tenantId || null)
126
+ const token = signJwt({
127
+ sub: String(user.id),
128
+ tenantId: tenantId || null,
129
+ orgId: user.organizationId ? String(user.organizationId) : null,
130
+ email: user.email,
131
+ roles,
132
+ })
133
+
134
+ await this.authService.updateLastLoginAt(user)
135
+
136
+ const days = Number(process.env.REMEMBER_ME_DAYS || '30')
137
+ const sessionExpiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
138
+ const session = await this.authService.createSession(user, sessionExpiresAt)
139
+
140
+ void emitSsoEvent('sso.login.completed', {
141
+ id: String(user.id),
142
+ tenantId: config.tenantId,
143
+ organizationId: config.organizationId,
144
+ }).catch((e) => console.error('[SSO Event]', e))
145
+
146
+ return {
147
+ token,
148
+ sessionToken: session.token,
149
+ sessionExpiresAt,
150
+ redirectUrl: flowState.returnUrl || '/backend',
151
+ tenantId: config.tenantId ?? null,
152
+ organizationId: config.organizationId,
153
+ }
154
+ }
155
+
156
+ private async decryptClientSecret(config: SsoConfig): Promise<string | undefined> {
157
+ if (!config.clientSecretEnc) return undefined
158
+
159
+ const decrypted = await this.tenantEncryptionService.decryptEntityPayload(
160
+ config.id,
161
+ { clientSecretEnc: config.clientSecretEnc },
162
+ config.tenantId,
163
+ config.organizationId,
164
+ )
165
+ return (decrypted.clientSecretEnc as string) ?? undefined
166
+ }
167
+ }
@@ -0,0 +1,56 @@
1
+ import type { RequiredEntityData } from '@mikro-orm/core'
2
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
3
+ import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
4
+ import { SsoConfig } from './data/entities'
5
+
6
+ export const setup: ModuleSetupConfig = {
7
+ defaultRoleFeatures: {
8
+ superadmin: ['sso.*'],
9
+ admin: ['sso.config.view', 'sso.config.manage', 'sso.scim.manage'],
10
+ },
11
+
12
+ async seedDefaults({ em, tenantId, organizationId, container }) {
13
+ if (process.env.NODE_ENV !== 'development') return
14
+ if (process.env.SSO_DEV_SEED !== 'true') return
15
+
16
+ const clientSecret = process.env.SSO_DEV_CLIENT_SECRET
17
+ if (!clientSecret) return
18
+
19
+ const domains = (process.env.SSO_DEV_ALLOWED_DOMAINS || 'example.com')
20
+ .split(',')
21
+ .map((d) => d.trim())
22
+ .filter(Boolean)
23
+
24
+ const existing = await em.findOne(SsoConfig, { organizationId })
25
+ if (existing) {
26
+ existing.allowedDomains = domains
27
+ await em.flush()
28
+ return
29
+ }
30
+
31
+ const encryptionService = container.resolve<TenantDataEncryptionService>('tenantEncryptionService')
32
+ const encrypted = await encryptionService.encryptEntityPayload(
33
+ 'SsoConfig',
34
+ { clientSecretEnc: clientSecret },
35
+ tenantId,
36
+ organizationId,
37
+ )
38
+
39
+ const config = em.create(SsoConfig, {
40
+ tenantId,
41
+ organizationId,
42
+ protocol: 'oidc',
43
+ issuer: process.env.SSO_DEV_ISSUER || 'http://localhost:8080/realms/open-mercato',
44
+ clientId: process.env.SSO_DEV_CLIENT_ID || 'open-mercato-app',
45
+ clientSecretEnc: encrypted.clientSecretEnc as string,
46
+ allowedDomains: domains,
47
+ jitEnabled: true,
48
+ autoLinkByEmail: true,
49
+ isActive: true,
50
+ ssoRequired: false,
51
+ } as RequiredEntityData<SsoConfig>)
52
+ await em.persistAndFlush(config)
53
+ },
54
+ }
55
+
56
+ export default setup
@@ -0,0 +1,33 @@
1
+ import type { EntityData } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { SsoIdentity, SsoRoleGrant, SsoUserDeactivation } from '../data/entities'
4
+
5
+ export const metadata = {
6
+ event: 'auth.user.deleted',
7
+ persistent: true,
8
+ id: 'sso:user-deleted-cleanup',
9
+ }
10
+
11
+ type UserDeletedPayload = {
12
+ userId: string
13
+ tenantId: string
14
+ organizationId: string
15
+ }
16
+
17
+ type ResolverContext = {
18
+ resolve: <T = unknown>(name: string) => T
19
+ }
20
+
21
+ export default async function handle(payload: UserDeletedPayload, ctx: ResolverContext) {
22
+ const em = ctx.resolve<EntityManager>('em')
23
+
24
+ await em.nativeUpdate(
25
+ SsoIdentity,
26
+ { userId: payload.userId, deletedAt: null },
27
+ { deletedAt: new Date() } as EntityData<SsoIdentity>,
28
+ )
29
+
30
+ await em.nativeDelete(SsoRoleGrant, { userId: payload.userId })
31
+
32
+ await em.nativeDelete(SsoUserDeactivation, { userId: payload.userId })
33
+ }