@_mustachio/openauth 0.12.0 → 0.13.0

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 (95) hide show
  1. package/dist/esm/domain/scim/discovery.js +206 -0
  2. package/dist/esm/domain/scim/filter.js +177 -0
  3. package/dist/esm/domain/scim/handle.js +338 -0
  4. package/dist/esm/domain/scim/patch.js +568 -0
  5. package/dist/esm/domain/scim/resource.js +256 -0
  6. package/dist/esm/http/handlers/scim.js +59 -0
  7. package/dist/esm/http/middleware/tenant.js +25 -0
  8. package/dist/esm/http/router.js +3 -0
  9. package/dist/esm/index.js +1 -0
  10. package/dist/esm/methods/saml-sp/acs.js +44 -20
  11. package/dist/esm/methods/saml-sp/attributes.js +2 -0
  12. package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
  13. package/dist/esm/methods/saml-sp/factory.js +12 -1
  14. package/dist/esm/methods/saml-sp/metadata.js +5 -3
  15. package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
  16. package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
  17. package/dist/esm/methods/saml-sp/sls.js +2 -2
  18. package/dist/esm/ports/scim-directory.js +0 -0
  19. package/dist/esm/types/error.js +5 -0
  20. package/dist/esm/types/scim.js +0 -0
  21. package/dist/types/domain/scim/discovery.d.ts +11 -0
  22. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  23. package/dist/types/domain/scim/filter.d.ts +51 -0
  24. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  25. package/dist/types/domain/scim/handle.d.ts +35 -0
  26. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  27. package/dist/types/domain/scim/patch.d.ts +73 -0
  28. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  29. package/dist/types/domain/scim/resource.d.ts +56 -0
  30. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  31. package/dist/types/http/context.d.ts +3 -0
  32. package/dist/types/http/context.d.ts.map +1 -1
  33. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  34. package/dist/types/http/handlers/scim.d.ts +3 -0
  35. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  36. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  37. package/dist/types/http/router.d.ts.map +1 -1
  38. package/dist/types/index.d.ts +2 -0
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
  41. package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
  42. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
  43. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
  44. package/dist/types/methods/saml-sp/factory.d.ts +217 -1
  45. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
  46. package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
  47. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
  48. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
  49. package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
  50. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
  51. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
  52. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
  53. package/dist/types/methods/saml-sp/types.d.ts +95 -0
  54. package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
  55. package/dist/types/ports/scim-directory.d.ts +104 -0
  56. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  57. package/dist/types/ports/session-store.d.ts.map +1 -1
  58. package/dist/types/types/error.d.ts +5 -0
  59. package/dist/types/types/error.d.ts.map +1 -1
  60. package/dist/types/types/idp.d.ts +12 -0
  61. package/dist/types/types/idp.d.ts.map +1 -1
  62. package/dist/types/types/scim.d.ts +269 -0
  63. package/dist/types/types/scim.d.ts.map +1 -0
  64. package/dist/types/types/tenant.d.ts +10 -0
  65. package/dist/types/types/tenant.d.ts.map +1 -1
  66. package/package.json +1 -1
  67. package/src/domain/scim/discovery.ts +262 -0
  68. package/src/domain/scim/filter.ts +295 -0
  69. package/src/domain/scim/handle.ts +597 -0
  70. package/src/domain/scim/patch.ts +807 -0
  71. package/src/domain/scim/resource.ts +358 -0
  72. package/src/http/context.ts +3 -0
  73. package/src/http/handlers/authorize.ts +0 -1
  74. package/src/http/handlers/method-route.ts +1 -4
  75. package/src/http/handlers/scim.ts +107 -0
  76. package/src/http/middleware/tenant.ts +47 -1
  77. package/src/http/router.ts +9 -0
  78. package/src/index.ts +20 -0
  79. package/src/methods/saml-sp/acs.ts +99 -49
  80. package/src/methods/saml-sp/attributes.ts +12 -3
  81. package/src/methods/saml-sp/authnrequest.ts +3 -2
  82. package/src/methods/saml-sp/factory.ts +24 -3
  83. package/src/methods/saml-sp/metadata.ts +19 -8
  84. package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
  85. package/src/methods/saml-sp/saml-instance.ts +42 -5
  86. package/src/methods/saml-sp/slo-initiate.ts +6 -3
  87. package/src/methods/saml-sp/sls.ts +3 -2
  88. package/src/methods/saml-sp/types.ts +95 -0
  89. package/src/ports/CONSISTENCY.md +28 -25
  90. package/src/ports/scim-directory.ts +166 -0
  91. package/src/ports/session-store.ts +1 -5
  92. package/src/types/error.ts +11 -0
  93. package/src/types/idp.ts +12 -0
  94. package/src/types/scim.ts +280 -0
  95. package/src/types/tenant.ts +10 -0
@@ -0,0 +1,807 @@
1
+ /**
2
+ * SCIM PATCH normalization (`SCIM-AD6`).
3
+ *
4
+ * `PATCH /Users/:id` carries a `PatchOp` whose `Operations` are the
5
+ * worst-specified part of SCIM, and Okta and Entra spell the same intent
6
+ * differently:
7
+ *
8
+ * Okta: { "op": "replace", "value": { "active": false } }
9
+ * Entra: { "op": "Replace", "path": "active", "value": "False" }
10
+ * Entra: { "op": "add", "path": "emails[type eq \"work\"].value", … }
11
+ *
12
+ * All three mean one thing to the host. This module resolves every
13
+ * supported shape — pathless object merges, dotted sub-attribute paths,
14
+ * multi-valued filter paths, the enterprise-extension URN prefix, and
15
+ * Entra's habit of sending booleans as the strings `"True"` / `"False"`
16
+ * — into a flat `ScimUserPatch` of fully resolved values.
17
+ *
18
+ * Resolution is done **against the current record**, which the caller
19
+ * has already fetched (it needs it for 404 semantics anyway). That means
20
+ * a targeted operation like `emails[type eq "work"].value` becomes a
21
+ * complete `emails` array, so the host implements exactly one semantic:
22
+ * present ⇒ set to this, `null` ⇒ clear, absent ⇒ leave alone. No merge
23
+ * logic and no path parsing ever reaches the host.
24
+ *
25
+ * Two kinds of "we can't do that" are treated very differently:
26
+ *
27
+ * - An attribute this Service Provider does not model at all (`title`,
28
+ * `nickName`, `locale`, …) is **skipped**. There is nowhere for it to
29
+ * go — `ScimUserPatch` has no field for it — so nothing drifts, and
30
+ * `parseUserWrite` already ignores the same attributes on POST/PUT.
31
+ * Rejecting them here would fail the whole request over an attribute
32
+ * we would have discarded anyway: Okta's default profile mappings
33
+ * push `title` in the *same* `PatchOp` as `active`, so a fatal
34
+ * unknown-attribute error takes the deactivation down with it, and
35
+ * Okta retries the identical payload forever.
36
+ * - A malformed operation on an attribute we **do** model (a
37
+ * non-boolean `active`, an unparseable path shape) stays an error.
38
+ * That one really would drift undetected.
39
+ *
40
+ * Pure: no I/O, no port access.
41
+ */
42
+ import type {
43
+ ScimGroupMember,
44
+ ScimGroupPatch,
45
+ ScimMultiValue,
46
+ ScimName,
47
+ ScimUserPatch,
48
+ ScimUserRecord,
49
+ } from "../../types/scim"
50
+ import { err, ok, type Result } from "../../types/result"
51
+
52
+ import {
53
+ parseMembers,
54
+ SCIM_ENTERPRISE_SCHEMA,
55
+ SCIM_PATCH_SCHEMA,
56
+ type ScimValidationError,
57
+ } from "./resource"
58
+
59
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
60
+ typeof v === "object" && v !== null && !Array.isArray(v)
61
+
62
+ /**
63
+ * Entra sends booleans as the strings `"True"` / `"False"` on
64
+ * `active`. Accepting those is not laxness for its own sake — rejecting
65
+ * them would fail every Entra deprovisioning, which is the single
66
+ * operation customers audit.
67
+ */
68
+ function coerceBoolean(v: unknown): boolean | undefined {
69
+ if (typeof v === "boolean") return v
70
+ if (typeof v === "string") {
71
+ const lowered = v.toLowerCase()
72
+ if (lowered === "true") return true
73
+ if (lowered === "false") return false
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ const asString = (v: unknown): string | undefined =>
79
+ typeof v === "string" ? v : undefined
80
+
81
+ /**
82
+ * Strip the enterprise-extension URN prefix from a path, if present.
83
+ *
84
+ * Two shapes arrive. A qualified path
85
+ * (`urn:…:enterprise:2.0:User:department`) carries the sub-attribute
86
+ * after a colon. A **pathless** op instead uses the bare URN as an
87
+ * object key, with the whole extension object as its value — and the
88
+ * recursion in `applyOperation` then hands that URN here as a path of
89
+ * its own. Matching only the colon-suffixed form missed the second case,
90
+ * and the URN's `2.0` made the result look like a dotted path.
91
+ */
92
+ function stripEnterprisePrefix(path: string): {
93
+ path: string
94
+ enterprise: boolean
95
+ } {
96
+ if (path.toLowerCase() === SCIM_ENTERPRISE_SCHEMA.toLowerCase()) {
97
+ return { path: "", enterprise: true }
98
+ }
99
+ const prefix = `${SCIM_ENTERPRISE_SCHEMA}:`
100
+ if (path.toLowerCase().startsWith(prefix.toLowerCase())) {
101
+ return { path: path.slice(prefix.length), enterprise: true }
102
+ }
103
+ const corePrefix = "urn:ietf:params:scim:schemas:core:2.0:user:"
104
+ if (path.toLowerCase().startsWith(corePrefix)) {
105
+ return { path: path.slice(corePrefix.length), enterprise: false }
106
+ }
107
+ return { path, enterprise: false }
108
+ }
109
+
110
+ /**
111
+ * Parse a multi-valued filter path such as
112
+ * `emails[type eq "work"].value` into its parts.
113
+ */
114
+ function parseMultiValuedPath(
115
+ path: string,
116
+ ): { attribute: string; type: string; sub: string } | null {
117
+ const m = /^([A-Za-z]+)\[\s*type\s+eq\s+"([^"]+)"\s*\]\.([A-Za-z]+)$/i.exec(
118
+ path.trim(),
119
+ )
120
+ if (!m) return null
121
+ return {
122
+ attribute: (m[1] as string).toLowerCase(),
123
+ type: m[2] as string,
124
+ sub: (m[3] as string).toLowerCase(),
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Upsert one entry into a multi-valued list, matching on `type`.
130
+ *
131
+ * Falls back to a single untyped entry when nothing matches by type. A
132
+ * user created from a payload whose email carried no `type` would
133
+ * otherwise gain a *second* email the first time Entra synced
134
+ * `emails[type eq "work"].value` — the untyped entry can never match, so
135
+ * every sync appends. Adopting the lone untyped entry (and labelling it)
136
+ * is the interpretation that keeps one address for one person; with two
137
+ * or more untyped entries there is no non-arbitrary choice, so we append.
138
+ */
139
+ function upsertByType(
140
+ current: ScimMultiValue[] | undefined,
141
+ type: string,
142
+ value: string,
143
+ ): ScimMultiValue[] {
144
+ const list = [...(current ?? [])]
145
+ const byType = list.findIndex(
146
+ (e) => (e.type ?? "").toLowerCase() === type.toLowerCase(),
147
+ )
148
+ if (byType >= 0) {
149
+ list[byType] = { ...(list[byType] as ScimMultiValue), value }
150
+ return list
151
+ }
152
+ const untyped = list.filter((e) => e.type === undefined)
153
+ if (untyped.length === 1) {
154
+ const idx = list.indexOf(untyped[0] as ScimMultiValue)
155
+ list[idx] = { ...(list[idx] as ScimMultiValue), value, type }
156
+ return list
157
+ }
158
+ list.push({ value, type })
159
+ return list
160
+ }
161
+
162
+ const NAME_SUBS = new Set([
163
+ "formatted",
164
+ "familyname",
165
+ "givenname",
166
+ "middlename",
167
+ "honorificprefix",
168
+ "honorificsuffix",
169
+ ])
170
+
171
+ const NAME_CANONICAL: Record<string, keyof ScimName> = {
172
+ formatted: "formatted",
173
+ familyname: "familyName",
174
+ givenname: "givenName",
175
+ middlename: "middleName",
176
+ honorificprefix: "honorificPrefix",
177
+ honorificsuffix: "honorificSuffix",
178
+ }
179
+
180
+ const ENTERPRISE_SUBS: Record<string, string> = {
181
+ employeenumber: "employeeNumber",
182
+ costcenter: "costCenter",
183
+ organization: "organization",
184
+ division: "division",
185
+ department: "department",
186
+ }
187
+
188
+ type Draft = {
189
+ patch: ScimUserPatch
190
+ /** Working copies so successive ops in one request compose. */
191
+ emails: ScimMultiValue[] | undefined
192
+ phoneNumbers: ScimMultiValue[] | undefined
193
+ name: ScimName | undefined
194
+ enterprise: Record<string, unknown> | undefined
195
+ }
196
+
197
+ function invalidPath(detail: string): ScimValidationError {
198
+ return { status: 400, scimType: "invalidPath", detail }
199
+ }
200
+
201
+ /**
202
+ * The attribute is outside this Service Provider's model — skip the
203
+ * operation rather than failing the request. See the note at the top of
204
+ * this file on why unmodelled ≠ malformed.
205
+ */
206
+ const SKIP = null
207
+
208
+ /** Apply one `{op, path, value}` to the working draft. */
209
+ function applyOperation(
210
+ draft: Draft,
211
+ op: string,
212
+ rawPath: string | undefined,
213
+ value: unknown,
214
+ ): ScimValidationError | null {
215
+ const operation = op.toLowerCase()
216
+ if (
217
+ operation !== "add" &&
218
+ operation !== "replace" &&
219
+ operation !== "remove"
220
+ ) {
221
+ return invalidPath(`unsupported PATCH op "${op}"`)
222
+ }
223
+
224
+ // --- Pathless op: the value is an object of attribute → new value.
225
+ if (rawPath === undefined || rawPath.trim().length === 0) {
226
+ if (operation === "remove") {
227
+ return invalidPath('"remove" requires a path')
228
+ }
229
+ if (!isRecord(value)) {
230
+ return invalidPath(
231
+ "a PATCH operation without a path must carry an object value",
232
+ )
233
+ }
234
+ for (const [k, v] of Object.entries(value)) {
235
+ const nested = applyOperation(draft, operation, k, v)
236
+ if (nested) return nested
237
+ }
238
+ return null
239
+ }
240
+
241
+ const { path: stripped, enterprise } = stripEnterprisePrefix(rawPath.trim())
242
+ const removing = operation === "remove"
243
+
244
+ // --- Multi-valued filter path: emails[type eq "work"].value
245
+ const mv = parseMultiValuedPath(stripped)
246
+ if (mv) {
247
+ if (mv.attribute !== "emails" && mv.attribute !== "phonenumbers") {
248
+ return SKIP // an unmodelled multi-valued attribute (ims, photos, …)
249
+ }
250
+ if (mv.sub !== "value") {
251
+ return invalidPath(
252
+ `only the ".value" sub-attribute of a multi-valued path is ` +
253
+ `supported, got ".${mv.sub}"`,
254
+ )
255
+ }
256
+ const key = mv.attribute === "emails" ? "emails" : "phoneNumbers"
257
+ const current =
258
+ mv.attribute === "emails" ? draft.emails : draft.phoneNumbers
259
+ if (removing) {
260
+ const filtered = (current ?? []).filter(
261
+ (e) => (e.type ?? "").toLowerCase() !== mv.type.toLowerCase(),
262
+ )
263
+ if (key === "emails") draft.emails = filtered
264
+ else draft.phoneNumbers = filtered
265
+ draft.patch[key] = filtered.length > 0 ? filtered : null
266
+ return null
267
+ }
268
+ const v = asString(value)
269
+ if (v === undefined) {
270
+ return {
271
+ status: 400,
272
+ scimType: "invalidValue",
273
+ detail: `value for "${rawPath}" must be a string`,
274
+ }
275
+ }
276
+ const next = upsertByType(current, mv.type, v)
277
+ if (key === "emails") draft.emails = next
278
+ else draft.phoneNumbers = next
279
+ draft.patch[key] = next
280
+ return null
281
+ }
282
+
283
+ if (stripped.includes("[")) {
284
+ return invalidPath(
285
+ `unsupported multi-valued path "${rawPath}"; only ` +
286
+ `<attr>[type eq "…"].value is supported`,
287
+ )
288
+ }
289
+
290
+ const segments = stripped.split(".")
291
+ const head = (segments[0] ?? "").toLowerCase()
292
+ const sub = segments[1]?.toLowerCase()
293
+ if (segments.length > 2) {
294
+ return invalidPath(`path "${rawPath}" is too deeply nested`)
295
+ }
296
+
297
+ // --- Enterprise extension sub-attributes.
298
+ if (enterprise) {
299
+ // Bare URN key: the value is the whole extension object, so fan out
300
+ // over its keys as if each had been sent as a qualified path.
301
+ if (stripped === "") {
302
+ if (removing) {
303
+ draft.enterprise = undefined
304
+ draft.patch.enterprise = null
305
+ return null
306
+ }
307
+ if (!isRecord(value)) {
308
+ return {
309
+ status: 400,
310
+ scimType: "invalidValue",
311
+ detail: "the enterprise extension requires an object value",
312
+ }
313
+ }
314
+ for (const [k, v] of Object.entries(value)) {
315
+ const nested = applyOperation(
316
+ draft,
317
+ operation,
318
+ `${SCIM_ENTERPRISE_SCHEMA}:${k}`,
319
+ v,
320
+ )
321
+ if (nested) return nested
322
+ }
323
+ return null
324
+ }
325
+ const canonical = ENTERPRISE_SUBS[head]
326
+ if (head === "manager") {
327
+ const current = { ...(draft.enterprise ?? {}) }
328
+ if (removing) delete current["manager"]
329
+ else {
330
+ const v = isRecord(value)
331
+ ? {
332
+ value: asString(value["value"]),
333
+ displayName: asString(value["displayName"]),
334
+ }
335
+ : { value: asString(value) }
336
+ current["manager"] = v
337
+ }
338
+ draft.enterprise = current
339
+ draft.patch.enterprise = current as ScimUserPatch["enterprise"]
340
+ return null
341
+ }
342
+ if (!canonical) {
343
+ return SKIP // unmodelled enterprise sub-attribute
344
+ }
345
+ const current = { ...(draft.enterprise ?? {}) }
346
+ if (removing) delete current[canonical]
347
+ else {
348
+ const v = asString(value)
349
+ if (v === undefined) {
350
+ return {
351
+ status: 400,
352
+ scimType: "invalidValue",
353
+ detail: `value for "${rawPath}" must be a string`,
354
+ }
355
+ }
356
+ current[canonical] = v
357
+ }
358
+ draft.enterprise = current
359
+ draft.patch.enterprise = current as ScimUserPatch["enterprise"]
360
+ return null
361
+ }
362
+
363
+ // --- name.<sub>
364
+ if (head === "name") {
365
+ if (sub === undefined) {
366
+ if (removing) {
367
+ draft.name = undefined
368
+ draft.patch.name = null
369
+ return null
370
+ }
371
+ if (!isRecord(value)) {
372
+ return invalidPath('"name" requires an object value')
373
+ }
374
+ // RFC 7644 §3.5.2.1: `add` on a complex attribute adds the given
375
+ // sub-attributes, leaving the others in place. Only `replace`
376
+ // substitutes the whole thing. Treating both as a replace silently
377
+ // cleared familyName whenever an IdP sent givenName alone.
378
+ const merged: ScimName =
379
+ operation === "add" ? { ...(draft.name ?? {}) } : {}
380
+ for (const [k, v] of Object.entries(value)) {
381
+ const canonical = NAME_CANONICAL[k.toLowerCase()]
382
+ const s = asString(v)
383
+ if (canonical && s !== undefined) merged[canonical] = s
384
+ }
385
+ draft.name = merged
386
+ draft.patch.name = Object.keys(merged).length > 0 ? merged : null
387
+ return null
388
+ }
389
+ if (!NAME_SUBS.has(sub)) {
390
+ return invalidPath(`name sub-attribute "${sub}" is not supported`)
391
+ }
392
+ const canonical = NAME_CANONICAL[sub] as keyof ScimName
393
+ const merged: ScimName = { ...(draft.name ?? {}) }
394
+ if (removing) delete merged[canonical]
395
+ else {
396
+ const s = asString(value)
397
+ if (s === undefined) {
398
+ return {
399
+ status: 400,
400
+ scimType: "invalidValue",
401
+ detail: `value for "${rawPath}" must be a string`,
402
+ }
403
+ }
404
+ merged[canonical] = s
405
+ }
406
+ draft.name = merged
407
+ draft.patch.name = Object.keys(merged).length > 0 ? merged : null
408
+ return null
409
+ }
410
+
411
+ if (sub !== undefined) {
412
+ return SKIP // a sub-attribute of something we do not model
413
+ }
414
+
415
+ // --- Simple top-level attributes.
416
+ switch (head) {
417
+ case "active": {
418
+ if (removing) {
419
+ return invalidPath('"active" cannot be removed; set it to false')
420
+ }
421
+ const b = coerceBoolean(value)
422
+ if (b === undefined) {
423
+ return {
424
+ status: 400,
425
+ scimType: "invalidValue",
426
+ detail: `"active" must be a boolean, got ${JSON.stringify(value)}`,
427
+ }
428
+ }
429
+ draft.patch.active = b
430
+ return null
431
+ }
432
+ case "username": {
433
+ if (removing) {
434
+ return invalidPath('"userName" cannot be removed')
435
+ }
436
+ const s = asString(value)
437
+ if (s === undefined || s.length === 0) {
438
+ return {
439
+ status: 400,
440
+ scimType: "invalidValue",
441
+ detail: '"userName" must be a non-empty string',
442
+ }
443
+ }
444
+ draft.patch.userName = s
445
+ return null
446
+ }
447
+ case "externalid": {
448
+ draft.patch.externalId = removing ? null : (asString(value) ?? null)
449
+ return null
450
+ }
451
+ case "displayname": {
452
+ draft.patch.displayName = removing ? null : (asString(value) ?? null)
453
+ return null
454
+ }
455
+ case "emails":
456
+ case "phonenumbers": {
457
+ const key = head === "emails" ? "emails" : "phoneNumbers"
458
+ if (removing) {
459
+ if (key === "emails") draft.emails = undefined
460
+ else draft.phoneNumbers = undefined
461
+ draft.patch[key] = null
462
+ return null
463
+ }
464
+ if (!Array.isArray(value)) {
465
+ return {
466
+ status: 400,
467
+ scimType: "invalidValue",
468
+ detail: `"${head}" must be an array`,
469
+ }
470
+ }
471
+ const list: ScimMultiValue[] = []
472
+ for (const entry of value) {
473
+ if (!isRecord(entry)) continue
474
+ const v = asString(entry["value"])
475
+ if (v === undefined) continue
476
+ const type = asString(entry["type"])
477
+ const primary = entry["primary"]
478
+ list.push({
479
+ value: v,
480
+ ...(type !== undefined ? { type } : {}),
481
+ ...(typeof primary === "boolean" ? { primary } : {}),
482
+ })
483
+ }
484
+ if (key === "emails") draft.emails = list
485
+ else draft.phoneNumbers = list
486
+ draft.patch[key] = list.length > 0 ? list : null
487
+ return null
488
+ }
489
+ default:
490
+ return SKIP // unmodelled attribute: title, nickName, locale, …
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Normalize a `PatchOp` body into a `ScimUserPatch`, resolved against
496
+ * `current`.
497
+ */
498
+ export function normalizePatch(
499
+ body: unknown,
500
+ current: ScimUserRecord,
501
+ ): Result<ScimUserPatch, ScimValidationError> {
502
+ if (!isRecord(body)) {
503
+ return err({
504
+ status: 400,
505
+ scimType: "invalidSyntax",
506
+ detail: "request body must be a JSON object",
507
+ })
508
+ }
509
+
510
+ const schemas = body["schemas"]
511
+ if (
512
+ Array.isArray(schemas) &&
513
+ !schemas.some(
514
+ (s) =>
515
+ typeof s === "string" &&
516
+ s.toLowerCase() === SCIM_PATCH_SCHEMA.toLowerCase(),
517
+ )
518
+ ) {
519
+ return err({
520
+ status: 400,
521
+ scimType: "invalidSyntax",
522
+ detail: `PATCH body must declare the ${SCIM_PATCH_SCHEMA} schema`,
523
+ })
524
+ }
525
+
526
+ // Okta and Entra both send "Operations"; accept the lowercase spelling
527
+ // too, which some smaller IdPs emit.
528
+ const rawOps = body["Operations"] ?? body["operations"]
529
+ if (!Array.isArray(rawOps) || rawOps.length === 0) {
530
+ return err({
531
+ status: 400,
532
+ scimType: "invalidSyntax",
533
+ detail: "PATCH body must carry a non-empty Operations array",
534
+ })
535
+ }
536
+
537
+ const draft: Draft = {
538
+ patch: {},
539
+ emails: current.emails ? [...current.emails] : undefined,
540
+ phoneNumbers: current.phoneNumbers ? [...current.phoneNumbers] : undefined,
541
+ name: current.name ? { ...current.name } : undefined,
542
+ enterprise: current.enterprise
543
+ ? ({ ...current.enterprise } as Record<string, unknown>)
544
+ : undefined,
545
+ }
546
+
547
+ for (const raw of rawOps) {
548
+ if (!isRecord(raw)) {
549
+ return err({
550
+ status: 400,
551
+ scimType: "invalidSyntax",
552
+ detail: "each PATCH operation must be an object",
553
+ })
554
+ }
555
+ const op = asString(raw["op"])
556
+ if (op === undefined) {
557
+ return err({
558
+ status: 400,
559
+ scimType: "invalidSyntax",
560
+ detail: "each PATCH operation must carry an op",
561
+ })
562
+ }
563
+ const failure = applyOperation(
564
+ draft,
565
+ op,
566
+ asString(raw["path"]),
567
+ raw["value"],
568
+ )
569
+ if (failure) return err(failure)
570
+ }
571
+
572
+ if (Object.keys(draft.patch).length === 0) {
573
+ return err({
574
+ status: 400,
575
+ scimType: "invalidValue",
576
+ detail: "PATCH resolved to no changes",
577
+ })
578
+ }
579
+
580
+ return ok(draft.patch)
581
+ }
582
+
583
+ /**
584
+ * Extract the member id from a `members[value eq "…"]` path.
585
+ *
586
+ * Note there is no `.sub` suffix here, unlike the user-side
587
+ * `emails[type eq "work"].value` — Okta targets the whole member entry
588
+ * for removal, not one of its sub-attributes.
589
+ */
590
+ function parseMemberFilterPath(path: string): string | null {
591
+ const m = /^members\[\s*value\s+eq\s+"([^"]+)"\s*\]$/i.exec(path.trim())
592
+ return m ? (m[1] as string) : null
593
+ }
594
+
595
+ /**
596
+ * Normalize a group `PatchOp`.
597
+ *
598
+ * Membership deliberately keeps the client's intent rather than being
599
+ * resolved to a final list (`SCIM-AD9`): "add one member" stays an add,
600
+ * so a host with a 20,000-member group issues one insert instead of
601
+ * rewriting the whole membership.
602
+ *
603
+ * A full replace still wins when one is present — subsequent
604
+ * adds/removes in the same request are folded into the replacement list,
605
+ * so the host never receives `members` alongside `addMembers` /
606
+ * `removeMembers` and needs no ordering rules of its own.
607
+ *
608
+ * Shapes handled:
609
+ *
610
+ * Okta: {op:"add", path:"members", value:[{value:"u1"}]}
611
+ * Okta: {op:"remove", path:"members[value eq \"u1\"]"}
612
+ * Entra: {op:"remove", path:"members", value:[{value:"u1"}]}
613
+ * both: {op:"replace", path:"members", value:[…]}
614
+ * both: {op:"replace", path:"displayName", value:"New"}
615
+ * both: {op:"replace", value:{displayName:"New"}}
616
+ */
617
+ export function normalizeGroupPatch(
618
+ body: unknown,
619
+ ): Result<ScimGroupPatch, ScimValidationError> {
620
+ if (!isRecord(body)) {
621
+ return err({
622
+ status: 400,
623
+ scimType: "invalidSyntax",
624
+ detail: "request body must be a JSON object",
625
+ })
626
+ }
627
+ const schemas = body["schemas"]
628
+ if (
629
+ Array.isArray(schemas) &&
630
+ !schemas.some(
631
+ (s) =>
632
+ typeof s === "string" &&
633
+ s.toLowerCase() === SCIM_PATCH_SCHEMA.toLowerCase(),
634
+ )
635
+ ) {
636
+ return err({
637
+ status: 400,
638
+ scimType: "invalidSyntax",
639
+ detail: `PATCH body must declare the ${SCIM_PATCH_SCHEMA} schema`,
640
+ })
641
+ }
642
+
643
+ const rawOps = body["Operations"] ?? body["operations"]
644
+ if (!Array.isArray(rawOps) || rawOps.length === 0) {
645
+ return err({
646
+ status: 400,
647
+ scimType: "invalidSyntax",
648
+ detail: "PATCH body must carry a non-empty Operations array",
649
+ })
650
+ }
651
+
652
+ const patch: ScimGroupPatch = {}
653
+ const added: ScimGroupMember[] = []
654
+ const removed: string[] = []
655
+ /** Non-null once a full replace has been seen. */
656
+ let replacement: ScimGroupMember[] | null = null
657
+
658
+ const applyAdd = (members: ScimGroupMember[]) => {
659
+ if (replacement !== null) {
660
+ for (const m of members) {
661
+ if (!replacement.some((e) => e.value === m.value)) replacement.push(m)
662
+ }
663
+ return
664
+ }
665
+ for (const m of members) {
666
+ if (!added.some((e) => e.value === m.value)) added.push(m)
667
+ }
668
+ }
669
+ const applyRemove = (ids: string[]) => {
670
+ if (replacement !== null) {
671
+ replacement = replacement.filter((e) => !ids.includes(e.value))
672
+ return
673
+ }
674
+ for (const id of ids) if (!removed.includes(id)) removed.push(id)
675
+ }
676
+
677
+ const handle = (
678
+ op: string,
679
+ rawPath: string | undefined,
680
+ value: unknown,
681
+ ): ScimValidationError | null => {
682
+ const operation = op.toLowerCase()
683
+ if (
684
+ operation !== "add" &&
685
+ operation !== "replace" &&
686
+ operation !== "remove"
687
+ ) {
688
+ return invalidPath(`unsupported PATCH op "${op}"`)
689
+ }
690
+
691
+ // Pathless: an object of attribute → value.
692
+ if (rawPath === undefined || rawPath.trim().length === 0) {
693
+ if (!isRecord(value)) {
694
+ return invalidPath(
695
+ "a PATCH operation without a path must carry an object value",
696
+ )
697
+ }
698
+ for (const [k, v] of Object.entries(value)) {
699
+ const nested = handle(operation, k, v)
700
+ if (nested) return nested
701
+ }
702
+ return null
703
+ }
704
+
705
+ const path = rawPath.trim()
706
+
707
+ const targeted = parseMemberFilterPath(path)
708
+ if (targeted !== null) {
709
+ if (operation !== "remove") {
710
+ return invalidPath(
711
+ `only "remove" is supported with a members[value eq …] path`,
712
+ )
713
+ }
714
+ applyRemove([targeted])
715
+ return null
716
+ }
717
+
718
+ if (path.toLowerCase() === "members") {
719
+ if (operation === "remove" && value === undefined) {
720
+ // "remove all members" — a replace with an empty list.
721
+ replacement = []
722
+ return null
723
+ }
724
+ const members = parseMembers(value)
725
+ if (members === undefined) {
726
+ return {
727
+ status: 400,
728
+ scimType: "invalidValue",
729
+ detail: '"members" must be an array of {value} entries',
730
+ }
731
+ }
732
+ if (operation === "add") applyAdd(members)
733
+ else if (operation === "remove") applyRemove(members.map((m) => m.value))
734
+ else replacement = [...members]
735
+ return null
736
+ }
737
+
738
+ if (path.includes("[")) {
739
+ return invalidPath(
740
+ `unsupported path "${rawPath}"; only members[value eq "…"] is ` +
741
+ `supported for targeted membership changes`,
742
+ )
743
+ }
744
+
745
+ switch (path.toLowerCase()) {
746
+ case "displayname": {
747
+ if (operation === "remove") {
748
+ return invalidPath('"displayName" cannot be removed')
749
+ }
750
+ const s = asString(value)
751
+ if (s === undefined || s.length === 0) {
752
+ return {
753
+ status: 400,
754
+ scimType: "invalidValue",
755
+ detail: '"displayName" must be a non-empty string',
756
+ }
757
+ }
758
+ patch.displayName = s
759
+ return null
760
+ }
761
+ case "externalid": {
762
+ patch.externalId =
763
+ operation === "remove" ? null : (asString(value) ?? null)
764
+ return null
765
+ }
766
+ default:
767
+ return invalidPath(`attribute "${rawPath}" is not patchable on a Group`)
768
+ }
769
+ }
770
+
771
+ for (const raw of rawOps) {
772
+ if (!isRecord(raw)) {
773
+ return err({
774
+ status: 400,
775
+ scimType: "invalidSyntax",
776
+ detail: "each PATCH operation must be an object",
777
+ })
778
+ }
779
+ const op = asString(raw["op"])
780
+ if (op === undefined) {
781
+ return err({
782
+ status: 400,
783
+ scimType: "invalidSyntax",
784
+ detail: "each PATCH operation must carry an op",
785
+ })
786
+ }
787
+ const failure = handle(op, asString(raw["path"]), raw["value"])
788
+ if (failure) return err(failure)
789
+ }
790
+
791
+ // A replace subsumes the incremental fields, so the host only ever
792
+ // sees one membership shape.
793
+ if (replacement !== null) patch.members = replacement
794
+ else {
795
+ if (added.length > 0) patch.addMembers = added
796
+ if (removed.length > 0) patch.removeMembers = removed
797
+ }
798
+
799
+ if (Object.keys(patch).length === 0) {
800
+ return err({
801
+ status: 400,
802
+ scimType: "invalidValue",
803
+ detail: "PATCH resolved to no changes",
804
+ })
805
+ }
806
+ return ok(patch)
807
+ }