@_mustachio/openauth 0.12.0 → 0.13.1
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 +12 -4
- 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 +20 -0
- 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/result.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 +98 -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 +35 -5
- package/src/methods/saml-sp/types.ts +97 -6
- 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 +13 -3
- package/src/types/result.ts +1 -2
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +12 -10
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCIM discovery documents — `/ServiceProviderConfig`, `/ResourceTypes`,
|
|
3
|
+
* `/Schemas`.
|
|
4
|
+
*
|
|
5
|
+
* Same advertise-only-what-we-serve invariant the SAML SP metadata
|
|
6
|
+
* endpoint holds: a capability we announce but do not implement is worse
|
|
7
|
+
* than one we never claimed, because the client will use it. So `bulk`,
|
|
8
|
+
* `sort`, `etag` and `changePassword` are all declared `false` — they
|
|
9
|
+
* are optional in RFC 7644 and deliberately out of scope
|
|
10
|
+
* (`SCIM-AD1` non-goals).
|
|
11
|
+
*
|
|
12
|
+
* `filter.supported` is `true` with a `maxResults`, which is honest: we
|
|
13
|
+
* do support filtering, on the documented subset (`SCIM-AD3`). SCIM has
|
|
14
|
+
* no way to advertise a partial filter grammar, so the narrowing is
|
|
15
|
+
* communicated where a client can actually act on it — the `400
|
|
16
|
+
* invalidFilter` response names exactly what works.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no I/O.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
SCIM_ENTERPRISE_SCHEMA,
|
|
22
|
+
SCIM_GROUP_SCHEMA,
|
|
23
|
+
SCIM_LIST_SCHEMA,
|
|
24
|
+
SCIM_USER_SCHEMA,
|
|
25
|
+
} from "./resource"
|
|
26
|
+
|
|
27
|
+
export function serviceProviderConfig(
|
|
28
|
+
baseUrl: string,
|
|
29
|
+
maxResults: number,
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
return {
|
|
32
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
|
33
|
+
documentationUri: "https://datatracker.ietf.org/doc/html/rfc7644",
|
|
34
|
+
patch: { supported: true },
|
|
35
|
+
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
|
|
36
|
+
filter: { supported: true, maxResults },
|
|
37
|
+
changePassword: { supported: false },
|
|
38
|
+
sort: { supported: false },
|
|
39
|
+
etag: { supported: false },
|
|
40
|
+
authenticationSchemes: [
|
|
41
|
+
{
|
|
42
|
+
type: "oauthbearertoken",
|
|
43
|
+
name: "OAuth Bearer Token",
|
|
44
|
+
description:
|
|
45
|
+
"Authentication via the bearer token issued for this SCIM connection",
|
|
46
|
+
specUri: "https://datatracker.ietf.org/doc/html/rfc6750",
|
|
47
|
+
primary: true,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
meta: {
|
|
51
|
+
resourceType: "ServiceProviderConfig",
|
|
52
|
+
location: `${baseUrl}/ServiceProviderConfig`,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `groups` reflects whether the host actually implemented the Group
|
|
59
|
+
* half of `ScimDirectory`. Advertising a resource type we answer 501 for
|
|
60
|
+
* would send a client straight into a failure it was told to expect to
|
|
61
|
+
* work — the same advertise-only-what-we-serve rule the SAML SP metadata
|
|
62
|
+
* endpoint follows.
|
|
63
|
+
*/
|
|
64
|
+
export function resourceTypes(
|
|
65
|
+
baseUrl: string,
|
|
66
|
+
groups: boolean,
|
|
67
|
+
): Record<string, unknown> {
|
|
68
|
+
const user = {
|
|
69
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
70
|
+
id: "User",
|
|
71
|
+
name: "User",
|
|
72
|
+
endpoint: "/Users",
|
|
73
|
+
description: "User Account",
|
|
74
|
+
schema: SCIM_USER_SCHEMA,
|
|
75
|
+
schemaExtensions: [{ schema: SCIM_ENTERPRISE_SCHEMA, required: false }],
|
|
76
|
+
meta: {
|
|
77
|
+
resourceType: "ResourceType",
|
|
78
|
+
location: `${baseUrl}/ResourceTypes/User`,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const group = {
|
|
82
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
|
|
83
|
+
id: "Group",
|
|
84
|
+
name: "Group",
|
|
85
|
+
endpoint: "/Groups",
|
|
86
|
+
description: "Group",
|
|
87
|
+
schema: SCIM_GROUP_SCHEMA,
|
|
88
|
+
meta: {
|
|
89
|
+
resourceType: "ResourceType",
|
|
90
|
+
location: `${baseUrl}/ResourceTypes/Group`,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resources = groups ? [user, group] : [user]
|
|
95
|
+
return {
|
|
96
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
97
|
+
totalResults: resources.length,
|
|
98
|
+
startIndex: 1,
|
|
99
|
+
itemsPerPage: resources.length,
|
|
100
|
+
Resources: resources,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type AttributeSpec = {
|
|
105
|
+
name: string
|
|
106
|
+
type: string
|
|
107
|
+
multiValued: boolean
|
|
108
|
+
required: boolean
|
|
109
|
+
caseExact?: boolean
|
|
110
|
+
mutability?: string
|
|
111
|
+
returned?: string
|
|
112
|
+
uniqueness?: string
|
|
113
|
+
subAttributes?: AttributeSpec[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const str = (
|
|
117
|
+
name: string,
|
|
118
|
+
extra: Partial<AttributeSpec> = {},
|
|
119
|
+
): AttributeSpec => ({
|
|
120
|
+
name,
|
|
121
|
+
type: "string",
|
|
122
|
+
multiValued: false,
|
|
123
|
+
required: false,
|
|
124
|
+
caseExact: false,
|
|
125
|
+
mutability: "readWrite",
|
|
126
|
+
returned: "default",
|
|
127
|
+
uniqueness: "none",
|
|
128
|
+
...extra,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const multiValueSub: AttributeSpec[] = [
|
|
132
|
+
str("value"),
|
|
133
|
+
str("type"),
|
|
134
|
+
{
|
|
135
|
+
name: "primary",
|
|
136
|
+
type: "boolean",
|
|
137
|
+
multiValued: false,
|
|
138
|
+
required: false,
|
|
139
|
+
mutability: "readWrite",
|
|
140
|
+
returned: "default",
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
export function schemas(
|
|
145
|
+
baseUrl: string,
|
|
146
|
+
groups: boolean,
|
|
147
|
+
): Record<string, unknown> {
|
|
148
|
+
const userSchema = {
|
|
149
|
+
id: SCIM_USER_SCHEMA,
|
|
150
|
+
name: "User",
|
|
151
|
+
description: "User Account",
|
|
152
|
+
attributes: [
|
|
153
|
+
str("userName", { required: true, uniqueness: "server" }),
|
|
154
|
+
{
|
|
155
|
+
name: "name",
|
|
156
|
+
type: "complex",
|
|
157
|
+
multiValued: false,
|
|
158
|
+
required: false,
|
|
159
|
+
mutability: "readWrite",
|
|
160
|
+
returned: "default",
|
|
161
|
+
subAttributes: [
|
|
162
|
+
str("formatted"),
|
|
163
|
+
str("familyName"),
|
|
164
|
+
str("givenName"),
|
|
165
|
+
str("middleName"),
|
|
166
|
+
str("honorificPrefix"),
|
|
167
|
+
str("honorificSuffix"),
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
str("displayName"),
|
|
171
|
+
{
|
|
172
|
+
name: "active",
|
|
173
|
+
type: "boolean",
|
|
174
|
+
multiValued: false,
|
|
175
|
+
required: false,
|
|
176
|
+
mutability: "readWrite",
|
|
177
|
+
returned: "default",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "emails",
|
|
181
|
+
type: "complex",
|
|
182
|
+
multiValued: true,
|
|
183
|
+
required: false,
|
|
184
|
+
mutability: "readWrite",
|
|
185
|
+
returned: "default",
|
|
186
|
+
subAttributes: multiValueSub,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: "phoneNumbers",
|
|
190
|
+
type: "complex",
|
|
191
|
+
multiValued: true,
|
|
192
|
+
required: false,
|
|
193
|
+
mutability: "readWrite",
|
|
194
|
+
returned: "default",
|
|
195
|
+
subAttributes: multiValueSub,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
meta: {
|
|
199
|
+
resourceType: "Schema",
|
|
200
|
+
location: `${baseUrl}/Schemas/${SCIM_USER_SCHEMA}`,
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const enterpriseSchema = {
|
|
205
|
+
id: SCIM_ENTERPRISE_SCHEMA,
|
|
206
|
+
name: "EnterpriseUser",
|
|
207
|
+
description: "Enterprise User Extension",
|
|
208
|
+
attributes: [
|
|
209
|
+
str("employeeNumber"),
|
|
210
|
+
str("costCenter"),
|
|
211
|
+
str("organization"),
|
|
212
|
+
str("division"),
|
|
213
|
+
str("department"),
|
|
214
|
+
{
|
|
215
|
+
name: "manager",
|
|
216
|
+
type: "complex",
|
|
217
|
+
multiValued: false,
|
|
218
|
+
required: false,
|
|
219
|
+
mutability: "readWrite",
|
|
220
|
+
returned: "default",
|
|
221
|
+
subAttributes: [str("value"), str("displayName")],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
meta: {
|
|
225
|
+
resourceType: "Schema",
|
|
226
|
+
location: `${baseUrl}/Schemas/${SCIM_ENTERPRISE_SCHEMA}`,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const groupSchema = {
|
|
231
|
+
id: SCIM_GROUP_SCHEMA,
|
|
232
|
+
name: "Group",
|
|
233
|
+
description: "Group",
|
|
234
|
+
attributes: [
|
|
235
|
+
str("displayName", { required: true }),
|
|
236
|
+
{
|
|
237
|
+
name: "members",
|
|
238
|
+
type: "complex",
|
|
239
|
+
multiValued: true,
|
|
240
|
+
required: false,
|
|
241
|
+
mutability: "readWrite",
|
|
242
|
+
returned: "default",
|
|
243
|
+
subAttributes: [str("value"), str("display"), str("type")],
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
meta: {
|
|
247
|
+
resourceType: "Schema",
|
|
248
|
+
location: `${baseUrl}/Schemas/${SCIM_GROUP_SCHEMA}`,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const resources = groups
|
|
253
|
+
? [userSchema, enterpriseSchema, groupSchema]
|
|
254
|
+
: [userSchema, enterpriseSchema]
|
|
255
|
+
return {
|
|
256
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
257
|
+
totalResults: resources.length,
|
|
258
|
+
startIndex: 1,
|
|
259
|
+
itemsPerPage: resources.length,
|
|
260
|
+
Resources: resources,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -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
|
+
}
|