@_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,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCIM request dispatch — the whole protocol surface, framework-free.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP layer parses the request into plain data and applies the
|
|
5
|
+
* result; every decision (auth, routing, validation, status codes)
|
|
6
|
+
* happens here, so the entire SCIM surface is testable without Hono.
|
|
7
|
+
* Same shape as `dispatchMethod`.
|
|
8
|
+
*
|
|
9
|
+
* Ordering is deliberate and security-relevant: authenticate before
|
|
10
|
+
* routing, so an unauthenticated caller cannot probe which endpoints
|
|
11
|
+
* exist; and distinguish "wrong token" (401) from "SCIM not enabled for
|
|
12
|
+
* this tenant" (403) without ever emitting a 404 that would confirm
|
|
13
|
+
* whether a tenant exists.
|
|
14
|
+
*/
|
|
15
|
+
import { timingSafeEqualStr } from "../crypto"
|
|
16
|
+
import { hashClientSecret } from "../token"
|
|
17
|
+
|
|
18
|
+
import type { ScimDirectory } from "../../ports/scim-directory"
|
|
19
|
+
import type { Result } from "../../types/result"
|
|
20
|
+
import type {
|
|
21
|
+
ScimConfig,
|
|
22
|
+
ScimGroupRecord,
|
|
23
|
+
ScimUserRecord,
|
|
24
|
+
ScimUserWrite,
|
|
25
|
+
} from "../../types/scim"
|
|
26
|
+
import type { TenantContext } from "../../types/tenant"
|
|
27
|
+
|
|
28
|
+
import { resourceTypes, schemas, serviceProviderConfig } from "./discovery"
|
|
29
|
+
import {
|
|
30
|
+
GROUP_FILTER_ATTRIBUTES,
|
|
31
|
+
parseScimFilter,
|
|
32
|
+
SUPPORTED_FILTER_HELP,
|
|
33
|
+
USER_FILTER_ATTRIBUTES,
|
|
34
|
+
} from "./filter"
|
|
35
|
+
import { normalizeGroupPatch, normalizePatch } from "./patch"
|
|
36
|
+
import {
|
|
37
|
+
parseGroupWrite,
|
|
38
|
+
parseUserWrite,
|
|
39
|
+
scimErrorBody,
|
|
40
|
+
serializeGroup,
|
|
41
|
+
serializeGroupList,
|
|
42
|
+
serializeList,
|
|
43
|
+
serializeUser,
|
|
44
|
+
type ScimErrorType,
|
|
45
|
+
} from "./resource"
|
|
46
|
+
|
|
47
|
+
/** Default page size when the client does not ask for one. */
|
|
48
|
+
const DEFAULT_PAGE_SIZE = 100
|
|
49
|
+
/**
|
|
50
|
+
* Ceiling on `maxPageSize`, whatever a tenant configures. A SCIM client
|
|
51
|
+
* controls `count`, so an unbounded value would let it ask the host for
|
|
52
|
+
* an arbitrarily large read.
|
|
53
|
+
*/
|
|
54
|
+
const PAGE_SIZE_CEILING = 500
|
|
55
|
+
|
|
56
|
+
export type ScimResponse = {
|
|
57
|
+
status: number
|
|
58
|
+
/** `null` for 204. Serialized as `application/scim+json` by the caller. */
|
|
59
|
+
body: Record<string, unknown> | null
|
|
60
|
+
/**
|
|
61
|
+
* Extra response headers the HTTP layer should set. Used for
|
|
62
|
+
* `Location` on a create, which RFC 7644 §3.1 requires and Okta's
|
|
63
|
+
* validator checks for.
|
|
64
|
+
*/
|
|
65
|
+
headers?: Record<string, string>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ScimRequestInput = {
|
|
69
|
+
tenant: TenantContext
|
|
70
|
+
/** Upper-case HTTP method. */
|
|
71
|
+
method: string
|
|
72
|
+
/** Path **within** the SCIM mount, e.g. `"/Users/abc"`. */
|
|
73
|
+
path: string
|
|
74
|
+
query: URLSearchParams
|
|
75
|
+
/** Parsed JSON body, or `null` for bodiless methods / unparseable input. */
|
|
76
|
+
body: unknown
|
|
77
|
+
/** Raw `Authorization` header value. */
|
|
78
|
+
authorization: string | null
|
|
79
|
+
/** Absolute base URL of the SCIM mount, e.g. `https://idp.example/scim/v2`. */
|
|
80
|
+
baseUrl: string
|
|
81
|
+
directory: ScimDirectory
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A 201 for a newly created resource. RFC 7644 §3.1 requires a
|
|
86
|
+
* `Location` header; the value is already computed as `meta.location`,
|
|
87
|
+
* so take it from there rather than rebuilding it and risking drift.
|
|
88
|
+
*/
|
|
89
|
+
function created(body: Record<string, unknown>): ScimResponse {
|
|
90
|
+
const location = (body["meta"] as Record<string, unknown> | undefined)?.[
|
|
91
|
+
"location"
|
|
92
|
+
]
|
|
93
|
+
return {
|
|
94
|
+
status: 201,
|
|
95
|
+
body,
|
|
96
|
+
...(typeof location === "string" ? { headers: { location } } : {}),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fail = (
|
|
101
|
+
status: number,
|
|
102
|
+
detail: string,
|
|
103
|
+
scimType?: ScimErrorType,
|
|
104
|
+
): ScimResponse => ({
|
|
105
|
+
status,
|
|
106
|
+
body: scimErrorBody(status, detail, scimType),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Map a host-returned `AuthError` onto a SCIM response.
|
|
111
|
+
*
|
|
112
|
+
* Three outcomes, and the distinction matters more than it looks,
|
|
113
|
+
* because SCIM clients **retry 5xx and give up on 4xx**:
|
|
114
|
+
*
|
|
115
|
+
* - `conflict` → `409 uniqueness`. A collision only the host can
|
|
116
|
+
* detect, since only the host stores the rows.
|
|
117
|
+
* - `invalid_request` → `400 invalidValue`. A **permanent** rejection:
|
|
118
|
+
* the host understood the request and will never accept it. The
|
|
119
|
+
* motivating case is group membership referencing a user the host
|
|
120
|
+
* does not have — Okta's group push can legitimately name a member
|
|
121
|
+
* that its user push filtered out, or one deleted between
|
|
122
|
+
* operations. Without this, such a request became a 500 and the IdP
|
|
123
|
+
* retried it forever instead of surfacing it to an admin.
|
|
124
|
+
* - anything else → `500`. Genuinely transient or unknown, so a retry
|
|
125
|
+
* is the right response; reporting success for a write that did not
|
|
126
|
+
* happen is how deprovisioning silently fails.
|
|
127
|
+
*
|
|
128
|
+
* The host's own message is passed through on the two 4xx paths — it is
|
|
129
|
+
* the only party that knows *why*, and that text is what an IdP admin
|
|
130
|
+
* sees in the provisioning log.
|
|
131
|
+
*/
|
|
132
|
+
function fromPortError(error: {
|
|
133
|
+
code: string
|
|
134
|
+
description: string
|
|
135
|
+
attribute?: string
|
|
136
|
+
}): ScimResponse {
|
|
137
|
+
if (error.code === "conflict") {
|
|
138
|
+
return fail(409, error.description, "uniqueness")
|
|
139
|
+
}
|
|
140
|
+
if (error.code === "invalid_request") {
|
|
141
|
+
return fail(400, error.description, "invalidValue")
|
|
142
|
+
}
|
|
143
|
+
return fail(500, "the directory backing this endpoint failed the request")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function unwrap<T>(r: Result<T>): { value: T } | { response: ScimResponse } {
|
|
147
|
+
if (r.ok) return { value: r.value }
|
|
148
|
+
return { response: fromPortError(r.error as never) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Constant-time bearer check against this tenant's configured token. */
|
|
152
|
+
async function authenticate(
|
|
153
|
+
scim: ScimConfig | undefined,
|
|
154
|
+
authorization: string | null,
|
|
155
|
+
): Promise<ScimResponse | null> {
|
|
156
|
+
if (!scim || scim.enabled !== true) {
|
|
157
|
+
// 403, never 404: a 404 here would let an unauthenticated caller
|
|
158
|
+
// probe which tenants exist.
|
|
159
|
+
return fail(403, "SCIM provisioning is not enabled for this tenant")
|
|
160
|
+
}
|
|
161
|
+
const prefix = "bearer "
|
|
162
|
+
if (
|
|
163
|
+
authorization === null ||
|
|
164
|
+
!authorization.toLowerCase().startsWith(prefix)
|
|
165
|
+
) {
|
|
166
|
+
return fail(401, "a Bearer token is required")
|
|
167
|
+
}
|
|
168
|
+
const presented = authorization.slice(prefix.length).trim()
|
|
169
|
+
if (presented.length === 0) return fail(401, "a Bearer token is required")
|
|
170
|
+
|
|
171
|
+
const hashed = await hashClientSecret(presented)
|
|
172
|
+
if (!timingSafeEqualStr(hashed, scim.tokenHash)) {
|
|
173
|
+
return fail(401, "invalid Bearer token")
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `/Users/<id>` → the decoded id; `/Users` → null. */
|
|
179
|
+
function userIdFrom(path: string): string | null {
|
|
180
|
+
const m = /^\/Users\/(.+)$/.exec(path)
|
|
181
|
+
if (!m) return null
|
|
182
|
+
try {
|
|
183
|
+
return decodeURIComponent(m[1] as string)
|
|
184
|
+
} catch {
|
|
185
|
+
return m[1] as string
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clampPaging(
|
|
190
|
+
query: URLSearchParams,
|
|
191
|
+
maxPageSize: number,
|
|
192
|
+
): { startIndex: number; count: number } {
|
|
193
|
+
const rawStart = Number(query.get("startIndex") ?? "1")
|
|
194
|
+
// RFC 7644 §3.4.2.4: 1-based, and a value < 1 is interpreted as 1.
|
|
195
|
+
const startIndex =
|
|
196
|
+
Number.isFinite(rawStart) && rawStart >= 1 ? Math.floor(rawStart) : 1
|
|
197
|
+
|
|
198
|
+
const rawCount = query.get("count")
|
|
199
|
+
if (rawCount === null) return { startIndex, count: maxPageSize }
|
|
200
|
+
const parsed = Number(rawCount)
|
|
201
|
+
// Unparseable is "unspecified" — fall back to the page size. A
|
|
202
|
+
// *negative* value is different: RFC 7644 §3.4.2.4 says it "SHALL be
|
|
203
|
+
// interpreted as 0", so `count=-1` must return nothing, not a full
|
|
204
|
+
// page.
|
|
205
|
+
if (!Number.isFinite(parsed)) return { startIndex, count: maxPageSize }
|
|
206
|
+
if (parsed < 0) return { startIndex, count: 0 }
|
|
207
|
+
return { startIndex, count: Math.min(Math.floor(parsed), maxPageSize) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function listUsers(
|
|
211
|
+
input: ScimRequestInput,
|
|
212
|
+
maxPageSize: number,
|
|
213
|
+
): Promise<ScimResponse> {
|
|
214
|
+
const filter = parseScimFilter(
|
|
215
|
+
input.query.get("filter"),
|
|
216
|
+
USER_FILTER_ATTRIBUTES,
|
|
217
|
+
)
|
|
218
|
+
if (!filter.ok) {
|
|
219
|
+
return fail(400, filter.error.detail, "invalidFilter")
|
|
220
|
+
}
|
|
221
|
+
const { startIndex, count } = clampPaging(input.query, maxPageSize)
|
|
222
|
+
|
|
223
|
+
const page = await input.directory.findUsers(input.tenant.id, {
|
|
224
|
+
...(filter.value !== undefined ? { filter: filter.value } : {}),
|
|
225
|
+
startIndex,
|
|
226
|
+
count,
|
|
227
|
+
})
|
|
228
|
+
const r = unwrap(page)
|
|
229
|
+
if ("response" in r) return r.response
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
status: 200,
|
|
233
|
+
body: serializeList(
|
|
234
|
+
r.value.resources,
|
|
235
|
+
r.value.totalResults,
|
|
236
|
+
startIndex,
|
|
237
|
+
input.baseUrl,
|
|
238
|
+
),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function createUser(input: ScimRequestInput): Promise<ScimResponse> {
|
|
243
|
+
const parsed = parseUserWrite(input.body)
|
|
244
|
+
if (!parsed.ok) {
|
|
245
|
+
return fail(parsed.error.status, parsed.error.detail, parsed.error.scimType)
|
|
246
|
+
}
|
|
247
|
+
const created_ = await input.directory.createUser(
|
|
248
|
+
input.tenant.id,
|
|
249
|
+
parsed.value,
|
|
250
|
+
)
|
|
251
|
+
const r = unwrap(created_)
|
|
252
|
+
if ("response" in r) return r.response
|
|
253
|
+
return created(serializeUser(r.value, input.baseUrl))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function loadUser(
|
|
257
|
+
input: ScimRequestInput,
|
|
258
|
+
id: string,
|
|
259
|
+
): Promise<{ user: ScimUserRecord } | { response: ScimResponse }> {
|
|
260
|
+
const found = await input.directory.getUser(input.tenant.id, id)
|
|
261
|
+
const r = unwrap(found)
|
|
262
|
+
if ("response" in r) return r
|
|
263
|
+
if (r.value === null) {
|
|
264
|
+
return { response: fail(404, `no User with id "${id}"`) }
|
|
265
|
+
}
|
|
266
|
+
return { user: r.value }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function replaceUser(
|
|
270
|
+
input: ScimRequestInput,
|
|
271
|
+
id: string,
|
|
272
|
+
): Promise<ScimResponse> {
|
|
273
|
+
const existing = await loadUser(input, id)
|
|
274
|
+
if ("response" in existing) return existing.response
|
|
275
|
+
|
|
276
|
+
const parsed = parseUserWrite(input.body)
|
|
277
|
+
if (!parsed.ok) {
|
|
278
|
+
return fail(parsed.error.status, parsed.error.detail, parsed.error.scimType)
|
|
279
|
+
}
|
|
280
|
+
const write: ScimUserWrite = parsed.value
|
|
281
|
+
const replaced = await input.directory.replaceUser(input.tenant.id, id, write)
|
|
282
|
+
const r = unwrap(replaced)
|
|
283
|
+
if ("response" in r) return r.response
|
|
284
|
+
return { status: 200, body: serializeUser(r.value, input.baseUrl) }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function patchUser(
|
|
288
|
+
input: ScimRequestInput,
|
|
289
|
+
id: string,
|
|
290
|
+
): Promise<ScimResponse> {
|
|
291
|
+
// The current record is needed for 404 semantics anyway, so resolving
|
|
292
|
+
// targeted patch paths against it costs nothing extra.
|
|
293
|
+
const existing = await loadUser(input, id)
|
|
294
|
+
if ("response" in existing) return existing.response
|
|
295
|
+
|
|
296
|
+
const normalized = normalizePatch(input.body, existing.user)
|
|
297
|
+
if (!normalized.ok) {
|
|
298
|
+
return fail(
|
|
299
|
+
normalized.error.status,
|
|
300
|
+
normalized.error.detail,
|
|
301
|
+
normalized.error.scimType,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
const patched = await input.directory.patchUser(
|
|
305
|
+
input.tenant.id,
|
|
306
|
+
id,
|
|
307
|
+
normalized.value,
|
|
308
|
+
)
|
|
309
|
+
const r = unwrap(patched)
|
|
310
|
+
if ("response" in r) return r.response
|
|
311
|
+
return { status: 200, body: serializeUser(r.value, input.baseUrl) }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function deleteUser(
|
|
315
|
+
input: ScimRequestInput,
|
|
316
|
+
id: string,
|
|
317
|
+
): Promise<ScimResponse> {
|
|
318
|
+
const existing = await loadUser(input, id)
|
|
319
|
+
if ("response" in existing) return existing.response
|
|
320
|
+
|
|
321
|
+
const deleted = await input.directory.deleteUser(input.tenant.id, id)
|
|
322
|
+
const r = unwrap(deleted)
|
|
323
|
+
if ("response" in r) return r.response
|
|
324
|
+
return { status: 204, body: null }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A directory that implements the whole optional Group half of the port.
|
|
329
|
+
*
|
|
330
|
+
* Narrowing to this type is what lets the group handlers call the
|
|
331
|
+
* methods directly instead of asserting each one non-null. The guard
|
|
332
|
+
* then holds structurally rather than by convention: a future call path
|
|
333
|
+
* that skips the check is a compile error, not a latent crash.
|
|
334
|
+
*/
|
|
335
|
+
type GroupCapableDirectory = ScimDirectory &
|
|
336
|
+
Required<
|
|
337
|
+
Pick<
|
|
338
|
+
ScimDirectory,
|
|
339
|
+
| "getGroup"
|
|
340
|
+
| "findGroups"
|
|
341
|
+
| "createGroup"
|
|
342
|
+
| "replaceGroup"
|
|
343
|
+
| "patchGroup"
|
|
344
|
+
| "deleteGroup"
|
|
345
|
+
>
|
|
346
|
+
>
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Groups are optional as a set on the port — a host that only needs user
|
|
350
|
+
* provisioning implements none of them and gets a clean 501 rather than
|
|
351
|
+
* a runtime failure mid-push.
|
|
352
|
+
*/
|
|
353
|
+
function groupsSupported(d: ScimDirectory): d is GroupCapableDirectory {
|
|
354
|
+
return (
|
|
355
|
+
typeof d.getGroup === "function" &&
|
|
356
|
+
typeof d.findGroups === "function" &&
|
|
357
|
+
typeof d.createGroup === "function" &&
|
|
358
|
+
typeof d.replaceGroup === "function" &&
|
|
359
|
+
typeof d.patchGroup === "function" &&
|
|
360
|
+
typeof d.deleteGroup === "function"
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** `excludedAttributes=members` — Okta sets it while enumerating. */
|
|
365
|
+
function excludesMembers(query: URLSearchParams): boolean {
|
|
366
|
+
const raw = query.get("excludedAttributes")
|
|
367
|
+
if (raw === null) return false
|
|
368
|
+
return raw
|
|
369
|
+
.split(",")
|
|
370
|
+
.map((s) => s.trim().toLowerCase())
|
|
371
|
+
.includes("members")
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function loadGroup(
|
|
375
|
+
input: ScimRequestInput,
|
|
376
|
+
groups: GroupCapableDirectory,
|
|
377
|
+
id: string,
|
|
378
|
+
): Promise<{ group: ScimGroupRecord } | { response: ScimResponse }> {
|
|
379
|
+
const found = await groups.getGroup(input.tenant.id, id)
|
|
380
|
+
const r = unwrap(found)
|
|
381
|
+
if ("response" in r) return r
|
|
382
|
+
if (r.value === null) {
|
|
383
|
+
return { response: fail(404, `no Group with id "${id}"`) }
|
|
384
|
+
}
|
|
385
|
+
return { group: r.value }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function handleGroups(
|
|
389
|
+
input: ScimRequestInput,
|
|
390
|
+
groups: GroupCapableDirectory,
|
|
391
|
+
path: string,
|
|
392
|
+
method: string,
|
|
393
|
+
maxPageSize: number,
|
|
394
|
+
): Promise<ScimResponse> {
|
|
395
|
+
if (path === "/Groups" || path === "/Groups/") {
|
|
396
|
+
if (method === "GET") {
|
|
397
|
+
const filter = parseScimFilter(
|
|
398
|
+
input.query.get("filter"),
|
|
399
|
+
GROUP_FILTER_ATTRIBUTES,
|
|
400
|
+
)
|
|
401
|
+
if (!filter.ok) return fail(400, filter.error.detail, "invalidFilter")
|
|
402
|
+
const { startIndex, count } = clampPaging(input.query, maxPageSize)
|
|
403
|
+
const page = await groups.findGroups(input.tenant.id, {
|
|
404
|
+
...(filter.value !== undefined ? { filter: filter.value } : {}),
|
|
405
|
+
startIndex,
|
|
406
|
+
count,
|
|
407
|
+
excludeMembers: excludesMembers(input.query),
|
|
408
|
+
})
|
|
409
|
+
const r = unwrap(page)
|
|
410
|
+
if ("response" in r) return r.response
|
|
411
|
+
return {
|
|
412
|
+
status: 200,
|
|
413
|
+
body: serializeGroupList(
|
|
414
|
+
r.value.resources,
|
|
415
|
+
r.value.totalResults,
|
|
416
|
+
startIndex,
|
|
417
|
+
input.baseUrl,
|
|
418
|
+
),
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (method === "POST") {
|
|
422
|
+
const parsed = parseGroupWrite(input.body)
|
|
423
|
+
if (!parsed.ok) {
|
|
424
|
+
return fail(
|
|
425
|
+
parsed.error.status,
|
|
426
|
+
parsed.error.detail,
|
|
427
|
+
parsed.error.scimType,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
const createdGroup = await groups.createGroup(
|
|
431
|
+
input.tenant.id,
|
|
432
|
+
parsed.value,
|
|
433
|
+
)
|
|
434
|
+
const r = unwrap(createdGroup)
|
|
435
|
+
if ("response" in r) return r.response
|
|
436
|
+
return created(serializeGroup(r.value, input.baseUrl))
|
|
437
|
+
}
|
|
438
|
+
return fail(405, `${method} is not allowed on /Groups`)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const m = /^\/Groups\/(.+)$/.exec(path)
|
|
442
|
+
if (!m) return fail(404, `unknown SCIM endpoint "${path}"`)
|
|
443
|
+
let id: string
|
|
444
|
+
try {
|
|
445
|
+
id = decodeURIComponent(m[1] as string)
|
|
446
|
+
} catch {
|
|
447
|
+
id = m[1] as string
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
switch (method) {
|
|
451
|
+
case "GET": {
|
|
452
|
+
const existing = await loadGroup(input, groups, id)
|
|
453
|
+
if ("response" in existing) return existing.response
|
|
454
|
+
return {
|
|
455
|
+
status: 200,
|
|
456
|
+
body: serializeGroup(existing.group, input.baseUrl),
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
case "PUT": {
|
|
460
|
+
const existing = await loadGroup(input, groups, id)
|
|
461
|
+
if ("response" in existing) return existing.response
|
|
462
|
+
const parsed = parseGroupWrite(input.body)
|
|
463
|
+
if (!parsed.ok) {
|
|
464
|
+
return fail(
|
|
465
|
+
parsed.error.status,
|
|
466
|
+
parsed.error.detail,
|
|
467
|
+
parsed.error.scimType,
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
const replaced = await groups.replaceGroup(
|
|
471
|
+
input.tenant.id,
|
|
472
|
+
id,
|
|
473
|
+
parsed.value,
|
|
474
|
+
)
|
|
475
|
+
const r = unwrap(replaced)
|
|
476
|
+
if ("response" in r) return r.response
|
|
477
|
+
return { status: 200, body: serializeGroup(r.value, input.baseUrl) }
|
|
478
|
+
}
|
|
479
|
+
case "PATCH": {
|
|
480
|
+
const existing = await loadGroup(input, groups, id)
|
|
481
|
+
if ("response" in existing) return existing.response
|
|
482
|
+
// Unlike a user patch, this needs no current record to resolve
|
|
483
|
+
// against — membership deltas stay deltas (SCIM-AD9). The read
|
|
484
|
+
// above is purely for 404 semantics.
|
|
485
|
+
const normalized = normalizeGroupPatch(input.body)
|
|
486
|
+
if (!normalized.ok) {
|
|
487
|
+
return fail(
|
|
488
|
+
normalized.error.status,
|
|
489
|
+
normalized.error.detail,
|
|
490
|
+
normalized.error.scimType,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
const patched = await groups.patchGroup(
|
|
494
|
+
input.tenant.id,
|
|
495
|
+
id,
|
|
496
|
+
normalized.value,
|
|
497
|
+
)
|
|
498
|
+
const r = unwrap(patched)
|
|
499
|
+
if ("response" in r) return r.response
|
|
500
|
+
return { status: 200, body: serializeGroup(r.value, input.baseUrl) }
|
|
501
|
+
}
|
|
502
|
+
case "DELETE": {
|
|
503
|
+
const existing = await loadGroup(input, groups, id)
|
|
504
|
+
if ("response" in existing) return existing.response
|
|
505
|
+
const deleted = await groups.deleteGroup(input.tenant.id, id)
|
|
506
|
+
const r = unwrap(deleted)
|
|
507
|
+
if ("response" in r) return r.response
|
|
508
|
+
return { status: 204, body: null }
|
|
509
|
+
}
|
|
510
|
+
default:
|
|
511
|
+
return fail(405, `${method} is not allowed on /Groups/{id}`)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Handle one SCIM request end to end.
|
|
517
|
+
*/
|
|
518
|
+
export async function handleScimRequest(
|
|
519
|
+
input: ScimRequestInput,
|
|
520
|
+
): Promise<ScimResponse> {
|
|
521
|
+
const scim = input.tenant.config.scim
|
|
522
|
+
|
|
523
|
+
const authFailure = await authenticate(scim, input.authorization)
|
|
524
|
+
if (authFailure) return authFailure
|
|
525
|
+
|
|
526
|
+
const maxPageSize = Math.min(
|
|
527
|
+
scim?.maxPageSize ?? DEFAULT_PAGE_SIZE,
|
|
528
|
+
PAGE_SIZE_CEILING,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
const path = input.path === "" ? "/" : input.path
|
|
532
|
+
const method = input.method.toUpperCase()
|
|
533
|
+
|
|
534
|
+
// --- Discovery. GET only; these are static documents.
|
|
535
|
+
if (
|
|
536
|
+
path === "/ServiceProviderConfig" ||
|
|
537
|
+
path === "/ResourceTypes" ||
|
|
538
|
+
path === "/Schemas"
|
|
539
|
+
) {
|
|
540
|
+
if (method !== "GET") {
|
|
541
|
+
return fail(405, `${method} is not allowed on ${path}`)
|
|
542
|
+
}
|
|
543
|
+
const groups = groupsSupported(input.directory)
|
|
544
|
+
const body =
|
|
545
|
+
path === "/ServiceProviderConfig"
|
|
546
|
+
? serviceProviderConfig(input.baseUrl, maxPageSize)
|
|
547
|
+
: path === "/ResourceTypes"
|
|
548
|
+
? resourceTypes(input.baseUrl, groups)
|
|
549
|
+
: schemas(input.baseUrl, groups)
|
|
550
|
+
return { status: 200, body }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --- Users collection.
|
|
554
|
+
if (path === "/Users" || path === "/Users/") {
|
|
555
|
+
if (method === "GET") return listUsers(input, maxPageSize)
|
|
556
|
+
if (method === "POST") return createUser(input)
|
|
557
|
+
return fail(405, `${method} is not allowed on /Users`)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- Individual user.
|
|
561
|
+
const id = userIdFrom(path)
|
|
562
|
+
if (id !== null) {
|
|
563
|
+
switch (method) {
|
|
564
|
+
case "GET": {
|
|
565
|
+
const existing = await loadUser(input, id)
|
|
566
|
+
if ("response" in existing) return existing.response
|
|
567
|
+
return {
|
|
568
|
+
status: 200,
|
|
569
|
+
body: serializeUser(existing.user, input.baseUrl),
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
case "PUT":
|
|
573
|
+
return replaceUser(input, id)
|
|
574
|
+
case "PATCH":
|
|
575
|
+
return patchUser(input, id)
|
|
576
|
+
case "DELETE":
|
|
577
|
+
// Never quietly remapped onto deactivation — see SCIM-AD8.
|
|
578
|
+
return deleteUser(input, id)
|
|
579
|
+
default:
|
|
580
|
+
return fail(405, `${method} is not allowed on /Users/{id}`)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (path.startsWith("/Groups")) {
|
|
585
|
+
if (!groupsSupported(input.directory)) {
|
|
586
|
+
return fail(
|
|
587
|
+
501,
|
|
588
|
+
"Group provisioning is not implemented by this Service Provider",
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
return handleGroups(input, input.directory, path, method, maxPageSize)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return fail(404, `unknown SCIM endpoint "${path}"`)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export { SUPPORTED_FILTER_HELP }
|