@_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.
- package/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +25 -0
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/methods/saml-sp/acs.js +44 -20
- package/dist/esm/methods/saml-sp/attributes.js +2 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
- package/dist/esm/methods/saml-sp/factory.js +12 -1
- package/dist/esm/methods/saml-sp/metadata.js +5 -3
- package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
- package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
- package/dist/esm/methods/saml-sp/sls.js +2 -2
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/scim.js +0 -0
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +3 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/factory.d.ts +217 -1
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/types.d.ts +95 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +12 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +3 -0
- package/src/http/handlers/authorize.ts +0 -1
- package/src/http/handlers/method-route.ts +1 -4
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +20 -0
- package/src/methods/saml-sp/acs.ts +99 -49
- package/src/methods/saml-sp/attributes.ts +12 -3
- package/src/methods/saml-sp/authnrequest.ts +3 -2
- package/src/methods/saml-sp/factory.ts +24 -3
- package/src/methods/saml-sp/metadata.ts +19 -8
- package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
- package/src/methods/saml-sp/saml-instance.ts +42 -5
- package/src/methods/saml-sp/slo-initiate.ts +6 -3
- package/src/methods/saml-sp/sls.ts +3 -2
- package/src/methods/saml-sp/types.ts +95 -0
- package/src/ports/CONSISTENCY.md +28 -25
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +1 -5
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +12 -0
- package/src/types/scim.ts +280 -0
- 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
|
+
}
|