@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,461 @@
1
+ import { EntityManager, type FilterQuery, type RequiredEntityData } from '@mikro-orm/postgresql'
2
+ import { User, Session } from '@open-mercato/core/modules/auth/data/entities'
3
+ import { computeEmailHash } from '@open-mercato/core/modules/auth/lib/emailHash'
4
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { SsoIdentity, SsoUserDeactivation, ScimProvisioningLog } from '../data/entities'
6
+ import { toScimUserResource, fromScimUserPayload, type ScimUserResource, type ScimUserPayload } from '../lib/scim-mapper'
7
+ import { coerceBoolean } from '../lib/scim-utils'
8
+ import { parseScimFilter, scimFilterToWhere } from '../lib/scim-filter'
9
+ import { buildListResponse } from '../lib/scim-response'
10
+ import type { ScimScope } from '../api/scim/context'
11
+ import type { ScimPatchOperation } from '../lib/scim-patch'
12
+
13
+ export class ScimService {
14
+ constructor(private em: EntityManager) {}
15
+
16
+ async createUser(
17
+ payload: Record<string, unknown>,
18
+ scope: ScimScope,
19
+ baseUrl: string,
20
+ ): Promise<{ resource: ScimUserResource; status: number }> {
21
+ const parsed = fromScimUserPayload(payload)
22
+ const email = parsed.email ?? parsed.userName
23
+ if (!email) {
24
+ throw new ScimServiceError(400, 'userName or emails[0].value is required')
25
+ }
26
+
27
+ // Idempotency: if externalId already exists for this config, return existing
28
+ if (parsed.externalId) {
29
+ const existingIdentity = await this.em.findOne(SsoIdentity, {
30
+ ssoConfigId: scope.ssoConfigId,
31
+ externalId: parsed.externalId,
32
+ deletedAt: null,
33
+ })
34
+ if (existingIdentity) {
35
+ const existingUser = await findOneWithDecryption(
36
+ this.em, User,
37
+ { id: existingIdentity.userId, deletedAt: null },
38
+ {},
39
+ { tenantId: scope.tenantId ?? '', organizationId: scope.organizationId },
40
+ )
41
+ if (existingUser) {
42
+ const deactivation = await this.em.findOne(SsoUserDeactivation, {
43
+ userId: existingUser.id, ssoConfigId: scope.ssoConfigId,
44
+ })
45
+ await this.log(scope, 'CREATE', existingIdentity.id, parsed.externalId, 200)
46
+ return {
47
+ resource: toScimUserResource(existingUser, existingIdentity, baseUrl, deactivation),
48
+ status: 200,
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ // Check if user already exists by email
55
+ const emailHash = computeEmailHash(email)
56
+ const where: FilterQuery<User> = {
57
+ organizationId: scope.organizationId,
58
+ deletedAt: null,
59
+ $or: [{ email }, { emailHash }],
60
+ }
61
+ const existingUser = await findOneWithDecryption(
62
+ this.em, User,
63
+ where,
64
+ {},
65
+ { tenantId: scope.tenantId ?? '', organizationId: scope.organizationId },
66
+ )
67
+
68
+ if (existingUser) {
69
+ // Check if already linked to this SSO config
70
+ const existingLink = await this.em.findOne(SsoIdentity, {
71
+ ssoConfigId: scope.ssoConfigId,
72
+ userId: existingUser.id,
73
+ deletedAt: null,
74
+ })
75
+ if (existingLink) {
76
+ throw new ScimServiceError(409, `User with email ${email} is already linked to this SSO configuration`)
77
+ }
78
+
79
+ // Auto-link: create SsoIdentity for existing user
80
+ const now = new Date()
81
+ const identity = this.em.create(SsoIdentity, {
82
+ tenantId: scope.tenantId ?? null,
83
+ organizationId: scope.organizationId,
84
+ ssoConfigId: scope.ssoConfigId,
85
+ userId: existingUser.id,
86
+ idpSubject: parsed.externalId ?? email,
87
+ idpEmail: email,
88
+ idpName: buildDisplayName(parsed),
89
+ idpGroups: [],
90
+ externalId: parsed.externalId ?? null,
91
+ provisioningMethod: 'scim',
92
+ createdAt: now,
93
+ updatedAt: now,
94
+ } as RequiredEntityData<SsoIdentity>)
95
+ await this.em.persistAndFlush(identity)
96
+
97
+ const deactivation = parsed.active === false
98
+ ? await this.createDeactivation(existingUser.id, scope)
99
+ : null
100
+
101
+ await this.log(scope, 'CREATE', identity.id, parsed.externalId, 201)
102
+ return {
103
+ resource: toScimUserResource(existingUser, identity, baseUrl, deactivation),
104
+ status: 201,
105
+ }
106
+ }
107
+
108
+ // Create new user + identity
109
+ return this.em.transactional(async (txEm) => {
110
+ const user = txEm.create(User, {
111
+ tenantId: scope.tenantId ?? null,
112
+ organizationId: scope.organizationId,
113
+ email,
114
+ emailHash: computeEmailHash(email),
115
+ name: buildDisplayName(parsed) ?? undefined,
116
+ passwordHash: null,
117
+ isConfirmed: true,
118
+ createdAt: new Date(),
119
+ })
120
+ await txEm.persistAndFlush(user)
121
+
122
+ const now = new Date()
123
+ const identity = txEm.create(SsoIdentity, {
124
+ tenantId: scope.tenantId ?? null,
125
+ organizationId: scope.organizationId,
126
+ ssoConfigId: scope.ssoConfigId,
127
+ userId: user.id,
128
+ idpSubject: parsed.externalId ?? email,
129
+ idpEmail: email,
130
+ idpName: buildDisplayName(parsed),
131
+ idpGroups: [],
132
+ externalId: parsed.externalId ?? null,
133
+ provisioningMethod: 'scim',
134
+ createdAt: now,
135
+ updatedAt: now,
136
+ } as RequiredEntityData<SsoIdentity>)
137
+ await txEm.persistAndFlush(identity)
138
+
139
+ const deactivation = parsed.active === false
140
+ ? await this.createDeactivationTx(txEm, user.id, scope)
141
+ : null
142
+
143
+ await this.logTx(txEm, scope, 'CREATE', identity.id, parsed.externalId, 201)
144
+ return {
145
+ resource: toScimUserResource(user, identity, baseUrl, deactivation),
146
+ status: 201,
147
+ }
148
+ })
149
+ }
150
+
151
+ async getUser(scimId: string, scope: ScimScope, baseUrl: string): Promise<ScimUserResource> {
152
+ const identity = await this.em.findOne(SsoIdentity, {
153
+ id: scimId,
154
+ ssoConfigId: scope.ssoConfigId,
155
+ organizationId: scope.organizationId,
156
+ deletedAt: null,
157
+ })
158
+ if (!identity) throw new ScimServiceError(404, 'User not found')
159
+
160
+ const user = await findOneWithDecryption(
161
+ this.em, User,
162
+ { id: identity.userId, deletedAt: null },
163
+ {},
164
+ { tenantId: scope.tenantId ?? '', organizationId: scope.organizationId },
165
+ )
166
+ if (!user) throw new ScimServiceError(404, 'User not found')
167
+
168
+ const deactivation = await this.em.findOne(SsoUserDeactivation, {
169
+ userId: user.id, ssoConfigId: scope.ssoConfigId,
170
+ })
171
+
172
+ return toScimUserResource(user, identity, baseUrl, deactivation)
173
+ }
174
+
175
+ async listUsers(
176
+ filter: string | null,
177
+ startIndex: number,
178
+ count: number,
179
+ scope: ScimScope,
180
+ baseUrl: string,
181
+ ): Promise<Record<string, unknown>> {
182
+ const conditions = parseScimFilter(filter)
183
+ const where = scimFilterToWhere(conditions, scope.ssoConfigId, scope.organizationId)
184
+
185
+ const offset = Math.max(0, startIndex - 1)
186
+ const [identities, total] = await this.em.findAndCount(SsoIdentity, where, {
187
+ orderBy: { createdAt: 'asc' },
188
+ limit: count,
189
+ offset,
190
+ })
191
+
192
+ const userIds = identities.map((i) => i.userId)
193
+
194
+ const users = userIds.length > 0
195
+ ? await findWithDecryption(
196
+ this.em, User,
197
+ { id: { $in: userIds }, deletedAt: null },
198
+ {},
199
+ { tenantId: scope.tenantId ?? '', organizationId: scope.organizationId },
200
+ )
201
+ : []
202
+ const userMap = new Map(users.map((u) => [u.id, u]))
203
+
204
+ const deactivations = userIds.length > 0
205
+ ? await this.em.find(SsoUserDeactivation, {
206
+ userId: { $in: userIds }, ssoConfigId: scope.ssoConfigId,
207
+ })
208
+ : []
209
+ const deactivationMap = new Map(deactivations.map((d) => [d.userId, d]))
210
+
211
+ const resources: ScimUserResource[] = []
212
+ for (const identity of identities) {
213
+ const user = userMap.get(identity.userId)
214
+ if (!user) continue
215
+
216
+ const deactivation = deactivationMap.get(user.id) ?? null
217
+ resources.push(toScimUserResource(user, identity, baseUrl, deactivation))
218
+ }
219
+
220
+ return buildListResponse(resources, total, startIndex, resources.length)
221
+ }
222
+
223
+ async patchUser(
224
+ scimId: string,
225
+ operations: ScimPatchOperation[],
226
+ scope: ScimScope,
227
+ baseUrl: string,
228
+ ): Promise<ScimUserResource> {
229
+ const identity = await this.em.findOne(SsoIdentity, {
230
+ id: scimId,
231
+ ssoConfigId: scope.ssoConfigId,
232
+ organizationId: scope.organizationId,
233
+ deletedAt: null,
234
+ })
235
+ if (!identity) throw new ScimServiceError(404, 'User not found')
236
+
237
+ const user = await findOneWithDecryption(
238
+ this.em, User,
239
+ { id: identity.userId, deletedAt: null },
240
+ {},
241
+ { tenantId: scope.tenantId ?? '', organizationId: scope.organizationId },
242
+ )
243
+ if (!user) throw new ScimServiceError(404, 'User not found')
244
+
245
+ for (const op of operations) {
246
+ const normalizedOp = op.op.toLowerCase()
247
+ if (normalizedOp === 'replace' || normalizedOp === 'add') {
248
+ this.applyPatchValue(user, identity, op.path, op.value)
249
+ }
250
+ // 'remove' operations on optional fields — set to null
251
+ if (normalizedOp === 'remove' && op.path) {
252
+ this.applyPatchValue(user, identity, op.path, null)
253
+ }
254
+ }
255
+
256
+ // Handle active status changes
257
+ const activeOp = operations.find((op) =>
258
+ op.path?.toLowerCase() === 'active' ||
259
+ (!op.path && op.value && typeof op.value === 'object' && 'active' in (op.value as Record<string, unknown>)),
260
+ )
261
+
262
+ if (activeOp) {
263
+ const activeValue = activeOp.path
264
+ ? coerceBoolean(activeOp.value)
265
+ : coerceBoolean((activeOp.value as Record<string, unknown>).active)
266
+
267
+ if (activeValue === false) {
268
+ await this.deactivateUser(user.id, scope)
269
+ } else if (activeValue === true) {
270
+ await this.reactivateUser(user.id, scope)
271
+ }
272
+ }
273
+
274
+ await this.em.flush()
275
+
276
+ const deactivation = await this.em.findOne(SsoUserDeactivation, {
277
+ userId: user.id, ssoConfigId: scope.ssoConfigId,
278
+ })
279
+
280
+ await this.log(scope, 'PATCH', identity.id, identity.externalId, 200)
281
+ return toScimUserResource(user, identity, baseUrl, deactivation)
282
+ }
283
+
284
+ async deleteUser(scimId: string, scope: ScimScope): Promise<void> {
285
+ const identity = await this.em.findOne(SsoIdentity, {
286
+ id: scimId,
287
+ ssoConfigId: scope.ssoConfigId,
288
+ organizationId: scope.organizationId,
289
+ deletedAt: null,
290
+ })
291
+ if (!identity) throw new ScimServiceError(404, 'User not found')
292
+
293
+ await this.deactivateUser(identity.userId, scope)
294
+ await this.log(scope, 'DELETE', identity.id, identity.externalId, 204)
295
+ }
296
+
297
+ private applyPatchValue(
298
+ user: User,
299
+ identity: SsoIdentity,
300
+ path: string | undefined,
301
+ value: unknown,
302
+ ): void {
303
+ if (!path) {
304
+ // No path means value is an object with attribute keys
305
+ if (value && typeof value === 'object') {
306
+ const obj = value as Record<string, unknown>
307
+ for (const [key, val] of Object.entries(obj)) {
308
+ this.applyPatchValue(user, identity, key, val)
309
+ }
310
+ }
311
+ return
312
+ }
313
+
314
+ const normalizedPath = path.toLowerCase()
315
+ switch (normalizedPath) {
316
+ case 'displayname':
317
+ user.name = (value as string) || undefined
318
+ identity.idpName = (value as string) ?? null
319
+ break
320
+ case 'name.givenname': {
321
+ const currentParts = (user.name ?? '').split(' ')
322
+ currentParts[0] = (value as string) ?? ''
323
+ user.name = currentParts.join(' ').trim() || undefined
324
+ break
325
+ }
326
+ case 'name.familyname': {
327
+ const currentParts = (user.name ?? '').split(' ')
328
+ const given = currentParts[0] ?? ''
329
+ user.name = value ? `${given} ${value}`.trim() : given || undefined
330
+ break
331
+ }
332
+ case 'username':
333
+ identity.idpEmail = (value as string) ?? identity.idpEmail
334
+ break
335
+ case 'externalid':
336
+ identity.externalId = (value as string) ?? null
337
+ break
338
+ case 'active':
339
+ // Handled separately via deactivation logic
340
+ break
341
+ }
342
+ }
343
+
344
+ private async deactivateUser(userId: string, scope: ScimScope): Promise<void> {
345
+ let deactivation = await this.em.findOne(SsoUserDeactivation, {
346
+ userId, ssoConfigId: scope.ssoConfigId,
347
+ })
348
+
349
+ if (deactivation) {
350
+ deactivation.deactivatedAt = new Date()
351
+ deactivation.reactivatedAt = null
352
+ } else {
353
+ deactivation = this.em.create(SsoUserDeactivation, {
354
+ tenantId: scope.tenantId ?? null,
355
+ organizationId: scope.organizationId,
356
+ userId,
357
+ ssoConfigId: scope.ssoConfigId,
358
+ deactivatedAt: new Date(),
359
+ } as RequiredEntityData<SsoUserDeactivation>)
360
+ this.em.persist(deactivation)
361
+ }
362
+ await this.em.flush()
363
+
364
+ // Revoke all active sessions
365
+ const sessionWhere: FilterQuery<Session> = { user: userId }
366
+ await this.em.nativeDelete(Session, sessionWhere)
367
+ }
368
+
369
+ private async reactivateUser(userId: string, scope: ScimScope): Promise<void> {
370
+ const deactivation = await this.em.findOne(SsoUserDeactivation, {
371
+ userId, ssoConfigId: scope.ssoConfigId,
372
+ })
373
+ if (deactivation && !deactivation.reactivatedAt) {
374
+ deactivation.reactivatedAt = new Date()
375
+ await this.em.flush()
376
+ }
377
+ }
378
+
379
+ private async createDeactivation(userId: string, scope: ScimScope): Promise<SsoUserDeactivation> {
380
+ const deactivation = this.em.create(SsoUserDeactivation, {
381
+ tenantId: scope.tenantId ?? null,
382
+ organizationId: scope.organizationId,
383
+ userId,
384
+ ssoConfigId: scope.ssoConfigId,
385
+ deactivatedAt: new Date(),
386
+ } as RequiredEntityData<SsoUserDeactivation>)
387
+ await this.em.persistAndFlush(deactivation)
388
+ return deactivation
389
+ }
390
+
391
+ private async createDeactivationTx(txEm: EntityManager, userId: string, scope: ScimScope): Promise<SsoUserDeactivation> {
392
+ const deactivation = txEm.create(SsoUserDeactivation, {
393
+ tenantId: scope.tenantId ?? null,
394
+ organizationId: scope.organizationId,
395
+ userId,
396
+ ssoConfigId: scope.ssoConfigId,
397
+ deactivatedAt: new Date(),
398
+ } as RequiredEntityData<SsoUserDeactivation>)
399
+ await txEm.persistAndFlush(deactivation)
400
+ return deactivation
401
+ }
402
+
403
+ private async log(
404
+ scope: ScimScope,
405
+ operation: string,
406
+ resourceId: string | null | undefined,
407
+ externalId: string | null | undefined,
408
+ responseStatus: number,
409
+ errorMessage?: string,
410
+ ): Promise<void> {
411
+ const entry = this.em.create(ScimProvisioningLog, {
412
+ tenantId: scope.tenantId ?? null,
413
+ organizationId: scope.organizationId,
414
+ ssoConfigId: scope.ssoConfigId,
415
+ operation,
416
+ resourceType: 'User',
417
+ resourceId: resourceId ?? null,
418
+ scimExternalId: externalId ?? null,
419
+ responseStatus,
420
+ errorMessage: errorMessage ?? null,
421
+ } as RequiredEntityData<ScimProvisioningLog>)
422
+ await this.em.persistAndFlush(entry)
423
+ }
424
+
425
+ private async logTx(
426
+ txEm: EntityManager,
427
+ scope: ScimScope,
428
+ operation: string,
429
+ resourceId: string | null | undefined,
430
+ externalId: string | null | undefined,
431
+ responseStatus: number,
432
+ ): Promise<void> {
433
+ const entry = txEm.create(ScimProvisioningLog, {
434
+ tenantId: scope.tenantId ?? null,
435
+ organizationId: scope.organizationId,
436
+ ssoConfigId: scope.ssoConfigId,
437
+ operation,
438
+ resourceType: 'User',
439
+ resourceId: resourceId ?? null,
440
+ scimExternalId: externalId ?? null,
441
+ responseStatus,
442
+ } as RequiredEntityData<ScimProvisioningLog>)
443
+ await txEm.persistAndFlush(entry)
444
+ }
445
+ }
446
+
447
+ function buildDisplayName(parsed: ScimUserPayload): string | null {
448
+ if (parsed.displayName) return parsed.displayName
449
+ const parts = [parsed.givenName, parsed.familyName].filter(Boolean)
450
+ return parts.length > 0 ? parts.join(' ') : null
451
+ }
452
+
453
+ export class ScimServiceError extends Error {
454
+ constructor(
455
+ public readonly statusCode: number,
456
+ message: string,
457
+ ) {
458
+ super(message)
459
+ this.name = 'ScimServiceError'
460
+ }
461
+ }
@@ -0,0 +1,136 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import type { RequiredEntityData } from '@mikro-orm/core'
3
+ import { EntityManager } from '@mikro-orm/postgresql'
4
+ import { hash, compare } from 'bcryptjs'
5
+ import { ScimToken, SsoConfig } from '../data/entities'
6
+ import type { SsoAdminScope } from './ssoConfigService'
7
+
8
+ const BCRYPT_COST = 10
9
+ const TOKEN_PREFIX = 'omscim_'
10
+
11
+ export interface ScimTokenPublic {
12
+ id: string
13
+ ssoConfigId: string
14
+ name: string
15
+ tokenPrefix: string
16
+ isActive: boolean
17
+ createdBy: string | null
18
+ createdAt: Date
19
+ }
20
+
21
+ export interface ScimTokenCreateResult {
22
+ id: string
23
+ token: string
24
+ prefix: string
25
+ name: string
26
+ }
27
+
28
+ export class ScimTokenService {
29
+ constructor(private em: EntityManager) {}
30
+
31
+ async generateToken(
32
+ ssoConfigId: string,
33
+ name: string,
34
+ scope: SsoAdminScope,
35
+ ): Promise<ScimTokenCreateResult> {
36
+ const where: Record<string, unknown> = { id: ssoConfigId, deletedAt: null }
37
+ if (!scope.isSuperAdmin && scope.organizationId) {
38
+ where.organizationId = scope.organizationId
39
+ }
40
+ const config = await this.em.findOne(SsoConfig, where)
41
+ if (!config) throw new ScimTokenError('SSO configuration not found', 404)
42
+ if (config.jitEnabled) {
43
+ throw new ScimTokenError('Cannot create SCIM tokens while JIT provisioning is enabled. Disable JIT first.', 409)
44
+ }
45
+
46
+ const raw = TOKEN_PREFIX + randomBytes(32).toString('hex')
47
+ const tokenHash = await hash(raw, BCRYPT_COST)
48
+ const tokenPrefix = raw.slice(0, 12)
49
+
50
+ const token = this.em.create(ScimToken, {
51
+ ssoConfigId,
52
+ name,
53
+ tokenHash,
54
+ tokenPrefix,
55
+ isActive: true,
56
+ createdBy: null,
57
+ tenantId: scope.tenantId,
58
+ organizationId: scope.organizationId!,
59
+ } as RequiredEntityData<ScimToken>)
60
+
61
+ await this.em.persistAndFlush(token)
62
+
63
+ return { id: token.id, token: raw, prefix: tokenPrefix, name }
64
+ }
65
+
66
+ async verifyToken(rawToken: string): Promise<{
67
+ ssoConfigId: string
68
+ organizationId: string
69
+ tenantId: string | null
70
+ } | null> {
71
+ const prefix = rawToken.slice(0, 12)
72
+
73
+ const candidates = await this.em.find(ScimToken, {
74
+ tokenPrefix: prefix,
75
+ isActive: true,
76
+ })
77
+
78
+ if (candidates.length === 0) {
79
+ await hash(rawToken, BCRYPT_COST)
80
+ return null
81
+ }
82
+
83
+ for (const candidate of candidates) {
84
+ const isValid = await compare(rawToken, candidate.tokenHash)
85
+ if (isValid) {
86
+ return {
87
+ ssoConfigId: candidate.ssoConfigId,
88
+ organizationId: candidate.organizationId,
89
+ tenantId: candidate.tenantId ?? null,
90
+ }
91
+ }
92
+ }
93
+
94
+ return null
95
+ }
96
+
97
+ async revokeToken(tokenId: string, scope: SsoAdminScope): Promise<void> {
98
+ const where: Record<string, unknown> = { id: tokenId }
99
+ if (!scope.isSuperAdmin) where.organizationId = scope.organizationId
100
+
101
+ const token = await this.em.findOne(ScimToken, where)
102
+ if (!token) throw new ScimTokenError('SCIM token not found', 404)
103
+
104
+ token.isActive = false
105
+ await this.em.flush()
106
+ }
107
+
108
+ async listTokens(ssoConfigId: string, scope: SsoAdminScope): Promise<ScimTokenPublic[]> {
109
+ const where: Record<string, unknown> = { ssoConfigId }
110
+ if (!scope.isSuperAdmin) where.organizationId = scope.organizationId
111
+
112
+ const tokens = await this.em.find(ScimToken, where, {
113
+ orderBy: { createdAt: 'desc' },
114
+ })
115
+
116
+ return tokens.map((t) => ({
117
+ id: t.id,
118
+ ssoConfigId: t.ssoConfigId,
119
+ name: t.name,
120
+ tokenPrefix: t.tokenPrefix,
121
+ isActive: t.isActive,
122
+ createdBy: t.createdBy ?? null,
123
+ createdAt: t.createdAt,
124
+ }))
125
+ }
126
+ }
127
+
128
+ export class ScimTokenError extends Error {
129
+ constructor(
130
+ message: string,
131
+ public readonly statusCode: number,
132
+ ) {
133
+ super(message)
134
+ this.name = 'ScimTokenError'
135
+ }
136
+ }