@_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,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCIM filter parsing — the deliberately narrow subset (`SCIM-AD3`).
|
|
3
|
+
*
|
|
4
|
+
* RFC 7644 §3.4.2.2 defines a full expression language: grouping,
|
|
5
|
+
* precedence, `co`/`sw`/`ew`/`pr`/`gt`/`ge`/`lt`/`le`, complex attribute
|
|
6
|
+
* paths, `not`, and arbitrary nesting. Okta and Entra emit a sliver of
|
|
7
|
+
* it — overwhelmingly `userName eq "…"` for the existence check before a
|
|
8
|
+
* create.
|
|
9
|
+
*
|
|
10
|
+
* Implementing the rest would mean either a large parser plus a query
|
|
11
|
+
* interface no host could reasonably satisfy, or quietly returning wrong
|
|
12
|
+
* results. We support the observed subset and reject everything else
|
|
13
|
+
* with a message naming what works, so a gap is visible on day one
|
|
14
|
+
* rather than becoming silent provisioning drift.
|
|
15
|
+
*
|
|
16
|
+
* Supported:
|
|
17
|
+
*
|
|
18
|
+
* userName eq "alice@corp.example"
|
|
19
|
+
* externalId eq "00u1abc"
|
|
20
|
+
* id eq "usr_123"
|
|
21
|
+
* active eq true
|
|
22
|
+
* emails[type eq "work"].value eq "a@b.com" (Entra's shape)
|
|
23
|
+
* emails.value eq "a@b.com"
|
|
24
|
+
* <term> and <term> (two terms, no nesting)
|
|
25
|
+
*
|
|
26
|
+
* Pure: no I/O, no port access. Returns a `Result` so the caller renders
|
|
27
|
+
* the SCIM error envelope.
|
|
28
|
+
*/
|
|
29
|
+
import type { ScimFilter, ScimFilterAttribute } from "../../types/scim"
|
|
30
|
+
import { err, ok, type Result } from "../../types/result"
|
|
31
|
+
|
|
32
|
+
/** What we tell a client when their filter is outside the subset. */
|
|
33
|
+
export const SUPPORTED_FILTER_HELP =
|
|
34
|
+
'supported filters are: userName eq "…", externalId eq "…", id eq "…", ' +
|
|
35
|
+
'active eq true|false, emails[type eq "…"].value eq "…", and two of ' +
|
|
36
|
+
'those joined by "and"'
|
|
37
|
+
|
|
38
|
+
export type ScimFilterError = { detail: string }
|
|
39
|
+
|
|
40
|
+
const ATTRIBUTES: Record<string, ScimFilterAttribute> = {
|
|
41
|
+
id: "id",
|
|
42
|
+
username: "userName",
|
|
43
|
+
externalid: "externalId",
|
|
44
|
+
active: "active",
|
|
45
|
+
"emails.value": "emails.value",
|
|
46
|
+
displayname: "displayName",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which attributes each resource type may be filtered on. Filtering a
|
|
51
|
+
* Group by `userName` is nonsense, and answering it with an empty list
|
|
52
|
+
* rather than a 400 would look like "no such group" — a wrong answer
|
|
53
|
+
* dressed as a valid one.
|
|
54
|
+
*/
|
|
55
|
+
export const USER_FILTER_ATTRIBUTES: ReadonlySet<ScimFilterAttribute> = new Set(
|
|
56
|
+
["id", "userName", "externalId", "active", "emails.value"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
export const GROUP_FILTER_ATTRIBUTES: ReadonlySet<ScimFilterAttribute> =
|
|
60
|
+
new Set(["id", "displayName", "externalId"])
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalize the complex multi-valued path Entra emits.
|
|
64
|
+
*
|
|
65
|
+
* `emails[type eq "work"].value` means "the value of the work email".
|
|
66
|
+
* We collapse the qualifier: the host matches on any email value. That
|
|
67
|
+
* is a deliberate simplification — a user with the same address under
|
|
68
|
+
* two `type`s is the same person, and hosts that model emails as a flat
|
|
69
|
+
* list (most of them) cannot honour the qualifier anyway.
|
|
70
|
+
*/
|
|
71
|
+
function normalizeComplexPath(raw: string): string {
|
|
72
|
+
const m = /^([A-Za-z]+)\[[^\]]*\]\.([A-Za-z]+)$/.exec(raw.trim())
|
|
73
|
+
if (!m) return raw.trim()
|
|
74
|
+
return `${m[1]}.${m[2]}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Locate the comparison operator at bracket depth 0, outside quotes.
|
|
79
|
+
*
|
|
80
|
+
* A naive regex splits `emails[type eq "work"].value eq "a@b.c"` on the
|
|
81
|
+
* *inner* `eq` and mis-reads the attribute as `emails[type`. The
|
|
82
|
+
* qualifier of a complex attribute contains its own operator, so the
|
|
83
|
+
* scan has to respect brackets.
|
|
84
|
+
*/
|
|
85
|
+
function findOperator(
|
|
86
|
+
term: string,
|
|
87
|
+
): { attr: string; op: string; value: string } | null {
|
|
88
|
+
let depth = 0
|
|
89
|
+
let inQuotes = false
|
|
90
|
+
for (let i = 0; i < term.length; i++) {
|
|
91
|
+
const ch = term[i]
|
|
92
|
+
if (ch === '"' && term[i - 1] !== "\\") {
|
|
93
|
+
inQuotes = !inQuotes
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (inQuotes) continue
|
|
97
|
+
if (ch === "[") depth++
|
|
98
|
+
else if (ch === "]") depth--
|
|
99
|
+
else if (depth === 0 && ch === " ") {
|
|
100
|
+
const m = /^\s+([A-Za-z]{2,3})(?:\s+|$)/.exec(term.slice(i))
|
|
101
|
+
if (m) {
|
|
102
|
+
return {
|
|
103
|
+
attr: term.slice(0, i),
|
|
104
|
+
op: m[1] as string,
|
|
105
|
+
value: term.slice(i + (m[0] as string).length),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse one `<attr> eq <value>` term. */
|
|
114
|
+
function parseTerm(
|
|
115
|
+
raw: string,
|
|
116
|
+
allowed: ReadonlySet<ScimFilterAttribute>,
|
|
117
|
+
): Result<ScimFilter, ScimFilterError> {
|
|
118
|
+
const term = raw.trim()
|
|
119
|
+
|
|
120
|
+
const found = findOperator(term)
|
|
121
|
+
if (!found) {
|
|
122
|
+
return err({
|
|
123
|
+
detail: `could not parse filter term "${term}". ${SUPPORTED_FILTER_HELP}`,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
const { attr: rawAttr, op: rawOp, value: rawValue } = found
|
|
127
|
+
|
|
128
|
+
if (rawOp.toLowerCase() !== "eq") {
|
|
129
|
+
return err({
|
|
130
|
+
detail:
|
|
131
|
+
`operator "${rawOp}" is not supported; only "eq" is. ` +
|
|
132
|
+
SUPPORTED_FILTER_HELP,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const attrKey = normalizeComplexPath(rawAttr).toLowerCase()
|
|
137
|
+
const attribute = ATTRIBUTES[attrKey]
|
|
138
|
+
if (!attribute || !allowed.has(attribute)) {
|
|
139
|
+
return err({
|
|
140
|
+
detail:
|
|
141
|
+
`attribute "${rawAttr.trim()}" is not filterable. ` +
|
|
142
|
+
SUPPORTED_FILTER_HELP,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const value = rawValue.trim()
|
|
147
|
+
|
|
148
|
+
if (attribute === "active") {
|
|
149
|
+
const lowered = value.toLowerCase()
|
|
150
|
+
if (lowered !== "true" && lowered !== "false") {
|
|
151
|
+
return err({
|
|
152
|
+
detail: `"active" must be compared to true or false, got ${value}`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
return ok({ op: "eq", attribute, value: lowered === "true" })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Every other supported attribute is a quoted string.
|
|
159
|
+
if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
|
|
160
|
+
return err({
|
|
161
|
+
detail: `value for "${attribute}" must be a quoted string, got ${value}`,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
const unquoted = value
|
|
165
|
+
.slice(1, -1)
|
|
166
|
+
// RFC 7644 uses JSON string escaping; only \" and \\ are plausible here.
|
|
167
|
+
.replace(/\\"/g, '"')
|
|
168
|
+
.replace(/\\\\/g, "\\")
|
|
169
|
+
|
|
170
|
+
if (unquoted.length === 0) {
|
|
171
|
+
return err({ detail: `value for "${attribute}" must not be empty` })
|
|
172
|
+
}
|
|
173
|
+
return ok({ op: "eq", attribute, value: unquoted })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parse a SCIM `filter` query parameter.
|
|
178
|
+
*
|
|
179
|
+
* Returns `ok(undefined)` for an absent or blank filter — an unfiltered
|
|
180
|
+
* list is legal and is what Okta sends when importing all users.
|
|
181
|
+
*/
|
|
182
|
+
export function parseScimFilter(
|
|
183
|
+
raw: string | null | undefined,
|
|
184
|
+
allowed: ReadonlySet<ScimFilterAttribute> = USER_FILTER_ATTRIBUTES,
|
|
185
|
+
): Result<ScimFilter | undefined, ScimFilterError> {
|
|
186
|
+
if (raw === null || raw === undefined || raw.trim().length === 0) {
|
|
187
|
+
return ok(undefined)
|
|
188
|
+
}
|
|
189
|
+
const input = raw.trim()
|
|
190
|
+
|
|
191
|
+
// Every structural check below runs against a *masked* copy in which
|
|
192
|
+
// the contents of quoted literals are blanked out. Testing the raw
|
|
193
|
+
// string rejected perfectly ordinary values — a group called
|
|
194
|
+
// "Sales (EMEA)" looked like a grouped expression, and a user called
|
|
195
|
+
// "jack or jill" looked like a disjunction. Both are common, and the
|
|
196
|
+
// damage is worse than a bad error message: Okta reacts to a failed
|
|
197
|
+
// existence lookup by creating a duplicate.
|
|
198
|
+
const masked = maskQuoted(input)
|
|
199
|
+
|
|
200
|
+
if (masked.includes("(") || masked.includes(")")) {
|
|
201
|
+
return err({
|
|
202
|
+
detail: `grouped expressions are not supported. ${SUPPORTED_FILTER_HELP}`,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Split on a top-level ` and `, ignoring one inside a quoted value or
|
|
207
|
+
// inside a complex-attribute qualifier (`emails[type eq "work"]`).
|
|
208
|
+
const parts = splitTopLevelAnd(input)
|
|
209
|
+
|
|
210
|
+
if (parts.length > 2) {
|
|
211
|
+
return err({
|
|
212
|
+
detail:
|
|
213
|
+
`at most two terms joined by "and" are supported. ` +
|
|
214
|
+
SUPPORTED_FILTER_HELP,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
if (/\sor\s/i.test(masked)) {
|
|
218
|
+
return err({
|
|
219
|
+
detail: `"or" is not supported. ${SUPPORTED_FILTER_HELP}`,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
if (/(^|\s)not\s/i.test(masked)) {
|
|
223
|
+
return err({
|
|
224
|
+
detail: `"not" is not supported. ${SUPPORTED_FILTER_HELP}`,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const first = parseTerm(parts[0] as string, allowed)
|
|
229
|
+
if (!first.ok) return first
|
|
230
|
+
if (parts.length === 1) return ok(first.value)
|
|
231
|
+
|
|
232
|
+
const second = parseTerm(parts[1] as string, allowed)
|
|
233
|
+
if (!second.ok) return second
|
|
234
|
+
return ok({ op: "and", left: first.value, right: second.value })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Replace the contents of every quoted literal with spaces, preserving
|
|
239
|
+
* length and the quote characters themselves. Lets the structural checks
|
|
240
|
+
* reason about filter *syntax* without tripping over filter *values*.
|
|
241
|
+
*/
|
|
242
|
+
function maskQuoted(input: string): string {
|
|
243
|
+
let out = ""
|
|
244
|
+
let inQuotes = false
|
|
245
|
+
for (let i = 0; i < input.length; i++) {
|
|
246
|
+
const ch = input[i] as string
|
|
247
|
+
if (ch === '"' && input[i - 1] !== "\\") {
|
|
248
|
+
inQuotes = !inQuotes
|
|
249
|
+
out += ch
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
out += inQuotes ? " " : ch
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Split on ` and ` at the top level only. Quoted strings and the
|
|
259
|
+
* `[...]` qualifier of a complex attribute may both legitimately
|
|
260
|
+
* contain the word, so a naive split corrupts them.
|
|
261
|
+
*/
|
|
262
|
+
function splitTopLevelAnd(input: string): string[] {
|
|
263
|
+
const parts: string[] = []
|
|
264
|
+
let depth = 0
|
|
265
|
+
let inQuotes = false
|
|
266
|
+
let start = 0
|
|
267
|
+
|
|
268
|
+
for (let i = 0; i < input.length; i++) {
|
|
269
|
+
const ch = input[i]
|
|
270
|
+
if (ch === '"' && input[i - 1] !== "\\") {
|
|
271
|
+
inQuotes = !inQuotes
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
if (inQuotes) continue
|
|
275
|
+
if (ch === "[") depth++
|
|
276
|
+
else if (ch === "]") depth--
|
|
277
|
+
else if (depth === 0 && (ch === "a" || ch === "A")) {
|
|
278
|
+
// Match a standalone " and " (case-insensitive) at this position.
|
|
279
|
+
const window = input.slice(i, i + 3)
|
|
280
|
+
const before = input[i - 1]
|
|
281
|
+
const after = input[i + 3]
|
|
282
|
+
if (
|
|
283
|
+
window.toLowerCase() === "and" &&
|
|
284
|
+
(before === " " || before === undefined) &&
|
|
285
|
+
(after === " " || after === undefined)
|
|
286
|
+
) {
|
|
287
|
+
parts.push(input.slice(start, i))
|
|
288
|
+
start = i + 3
|
|
289
|
+
i += 2
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
parts.push(input.slice(start))
|
|
294
|
+
return parts.map((p) => p.trim()).filter((p) => p.length > 0)
|
|
295
|
+
}
|