@_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,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCIM resource shapes — validation in, serialization out, and the
|
|
3
|
+
* error envelope.
|
|
4
|
+
*
|
|
5
|
+
* RFC 7643 defines the User schema; RFC 7644 defines the message
|
|
6
|
+
* envelopes. Both are picky in ways that are easy to get subtly wrong
|
|
7
|
+
* and that Okta's validator checks, so the details here are asserted in
|
|
8
|
+
* tests rather than assumed:
|
|
9
|
+
*
|
|
10
|
+
* - `status` in an Error is a **string**, not a number.
|
|
11
|
+
* - `ListResponse` uses `Resources` with a capital R.
|
|
12
|
+
* - `startIndex` is **1-based**.
|
|
13
|
+
* - `meta.location` must be an absolute URL; Okta reads it.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no I/O, no port access.
|
|
16
|
+
*/
|
|
17
|
+
import type {
|
|
18
|
+
ScimEnterpriseUser,
|
|
19
|
+
ScimGroupMember,
|
|
20
|
+
ScimGroupRecord,
|
|
21
|
+
ScimGroupWrite,
|
|
22
|
+
ScimMultiValue,
|
|
23
|
+
ScimName,
|
|
24
|
+
ScimUserRecord,
|
|
25
|
+
ScimUserWrite,
|
|
26
|
+
} from "../../types/scim"
|
|
27
|
+
import { err, ok, type Result } from "../../types/result"
|
|
28
|
+
|
|
29
|
+
export const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
|
|
30
|
+
export const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
|
|
31
|
+
export const SCIM_ENTERPRISE_SCHEMA =
|
|
32
|
+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
|
33
|
+
export const SCIM_LIST_SCHEMA =
|
|
34
|
+
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
35
|
+
export const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
|
|
36
|
+
export const SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
|
37
|
+
|
|
38
|
+
/** RFC 7644 §3.12 `scimType` values we emit. */
|
|
39
|
+
export type ScimErrorType =
|
|
40
|
+
| "invalidFilter"
|
|
41
|
+
| "invalidPath"
|
|
42
|
+
| "invalidSyntax"
|
|
43
|
+
| "invalidValue"
|
|
44
|
+
| "mutability"
|
|
45
|
+
| "noTarget"
|
|
46
|
+
| "uniqueness"
|
|
47
|
+
| "tooMany"
|
|
48
|
+
|
|
49
|
+
export type ScimValidationError = {
|
|
50
|
+
status: number
|
|
51
|
+
detail: string
|
|
52
|
+
scimType?: ScimErrorType
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** RFC 7644 §3.12 error envelope. */
|
|
56
|
+
export function scimErrorBody(
|
|
57
|
+
status: number,
|
|
58
|
+
detail: string,
|
|
59
|
+
scimType?: ScimErrorType,
|
|
60
|
+
): Record<string, unknown> {
|
|
61
|
+
return {
|
|
62
|
+
schemas: [SCIM_ERROR_SCHEMA],
|
|
63
|
+
// A string, per the RFC's own examples — a number here is a common
|
|
64
|
+
// implementation bug and Okta's validator notices.
|
|
65
|
+
status: String(status),
|
|
66
|
+
...(scimType !== undefined ? { scimType } : {}),
|
|
67
|
+
detail,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
72
|
+
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
73
|
+
|
|
74
|
+
const asString = (v: unknown): string | undefined =>
|
|
75
|
+
typeof v === "string" && v.length > 0 ? v : undefined
|
|
76
|
+
|
|
77
|
+
function parseName(v: unknown): ScimName | undefined {
|
|
78
|
+
if (!isRecord(v)) return undefined
|
|
79
|
+
const out: ScimName = {}
|
|
80
|
+
for (const k of [
|
|
81
|
+
"formatted",
|
|
82
|
+
"familyName",
|
|
83
|
+
"givenName",
|
|
84
|
+
"middleName",
|
|
85
|
+
"honorificPrefix",
|
|
86
|
+
"honorificSuffix",
|
|
87
|
+
] as const) {
|
|
88
|
+
const s = asString(v[k])
|
|
89
|
+
if (s !== undefined) out[k] = s
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseMultiValues(v: unknown): ScimMultiValue[] | undefined {
|
|
95
|
+
if (!Array.isArray(v)) return undefined
|
|
96
|
+
const out: ScimMultiValue[] = []
|
|
97
|
+
for (const entry of v) {
|
|
98
|
+
if (!isRecord(entry)) continue
|
|
99
|
+
const value = asString(entry["value"])
|
|
100
|
+
if (value === undefined) continue
|
|
101
|
+
const type = asString(entry["type"])
|
|
102
|
+
const primary = entry["primary"]
|
|
103
|
+
out.push({
|
|
104
|
+
value,
|
|
105
|
+
...(type !== undefined ? { type } : {}),
|
|
106
|
+
...(typeof primary === "boolean" ? { primary } : {}),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return out.length > 0 ? out : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseEnterprise(v: unknown): ScimEnterpriseUser | undefined {
|
|
113
|
+
if (!isRecord(v)) return undefined
|
|
114
|
+
const out: ScimEnterpriseUser = {}
|
|
115
|
+
for (const k of [
|
|
116
|
+
"employeeNumber",
|
|
117
|
+
"costCenter",
|
|
118
|
+
"organization",
|
|
119
|
+
"division",
|
|
120
|
+
"department",
|
|
121
|
+
] as const) {
|
|
122
|
+
const s = asString(v[k])
|
|
123
|
+
if (s !== undefined) out[k] = s
|
|
124
|
+
}
|
|
125
|
+
const mgr = v["manager"]
|
|
126
|
+
if (isRecord(mgr)) {
|
|
127
|
+
const value = asString(mgr["value"])
|
|
128
|
+
const displayName = asString(mgr["displayName"])
|
|
129
|
+
if (value !== undefined || displayName !== undefined) {
|
|
130
|
+
out.manager = {
|
|
131
|
+
...(value !== undefined ? { value } : {}),
|
|
132
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate a `POST` / `PUT` user payload into a `ScimUserWrite`.
|
|
141
|
+
*
|
|
142
|
+
* Unknown attributes are ignored rather than rejected: SCIM clients send
|
|
143
|
+
* plenty the spec allows and we do not model, and failing the whole
|
|
144
|
+
* provisioning run over an extra field would be hostile. The two
|
|
145
|
+
* exceptions are hard errors — a missing `userName`, which leaves the
|
|
146
|
+
* record unidentifiable, and `password`, which we refuse to handle at
|
|
147
|
+
* all rather than accept and drop.
|
|
148
|
+
*/
|
|
149
|
+
export function parseUserWrite(
|
|
150
|
+
body: unknown,
|
|
151
|
+
): Result<ScimUserWrite, ScimValidationError> {
|
|
152
|
+
if (!isRecord(body)) {
|
|
153
|
+
return err({
|
|
154
|
+
status: 400,
|
|
155
|
+
scimType: "invalidSyntax",
|
|
156
|
+
detail: "request body must be a JSON object",
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (body["password"] !== undefined) {
|
|
161
|
+
return err({
|
|
162
|
+
status: 400,
|
|
163
|
+
scimType: "invalidValue",
|
|
164
|
+
detail:
|
|
165
|
+
"this Service Provider does not accept 'password' over SCIM; " +
|
|
166
|
+
"credentials are managed by the authentication methods, not the " +
|
|
167
|
+
"directory feed",
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const userName = asString(body["userName"])
|
|
172
|
+
if (userName === undefined) {
|
|
173
|
+
return err({
|
|
174
|
+
status: 400,
|
|
175
|
+
scimType: "invalidValue",
|
|
176
|
+
detail: "userName is required and must be a non-empty string",
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const activeRaw = body["active"]
|
|
181
|
+
if (activeRaw !== undefined && typeof activeRaw !== "boolean") {
|
|
182
|
+
return err({
|
|
183
|
+
status: 400,
|
|
184
|
+
scimType: "invalidValue",
|
|
185
|
+
detail: "active must be a boolean",
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const externalId = asString(body["externalId"])
|
|
190
|
+
const displayName = asString(body["displayName"])
|
|
191
|
+
const name = parseName(body["name"])
|
|
192
|
+
const emails = parseMultiValues(body["emails"])
|
|
193
|
+
const phoneNumbers = parseMultiValues(body["phoneNumbers"])
|
|
194
|
+
const enterprise = parseEnterprise(body[SCIM_ENTERPRISE_SCHEMA])
|
|
195
|
+
|
|
196
|
+
return ok({
|
|
197
|
+
userName,
|
|
198
|
+
// SCIM defaults `active` to true on create. Resolving the default
|
|
199
|
+
// here keeps the semantics in one place instead of in every host.
|
|
200
|
+
active: activeRaw === undefined ? true : activeRaw,
|
|
201
|
+
...(externalId !== undefined ? { externalId } : {}),
|
|
202
|
+
...(displayName !== undefined ? { displayName } : {}),
|
|
203
|
+
...(name !== undefined ? { name } : {}),
|
|
204
|
+
...(emails !== undefined ? { emails } : {}),
|
|
205
|
+
...(phoneNumbers !== undefined ? { phoneNumbers } : {}),
|
|
206
|
+
...(enterprise !== undefined ? { enterprise } : {}),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const iso = (ms: number | undefined): string | undefined =>
|
|
211
|
+
ms === undefined ? undefined : new Date(ms).toISOString()
|
|
212
|
+
|
|
213
|
+
/** Serialize a host record into a SCIM User resource. */
|
|
214
|
+
export function serializeUser(
|
|
215
|
+
user: ScimUserRecord,
|
|
216
|
+
baseUrl: string,
|
|
217
|
+
): Record<string, unknown> {
|
|
218
|
+
const schemas = [SCIM_USER_SCHEMA]
|
|
219
|
+
if (user.enterprise !== undefined) schemas.push(SCIM_ENTERPRISE_SCHEMA)
|
|
220
|
+
|
|
221
|
+
const created = iso(user.createdAt)
|
|
222
|
+
const lastModified = iso(user.updatedAt)
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
schemas,
|
|
226
|
+
id: user.id,
|
|
227
|
+
...(user.externalId !== undefined ? { externalId: user.externalId } : {}),
|
|
228
|
+
userName: user.userName,
|
|
229
|
+
...(user.name !== undefined ? { name: user.name } : {}),
|
|
230
|
+
...(user.displayName !== undefined
|
|
231
|
+
? { displayName: user.displayName }
|
|
232
|
+
: {}),
|
|
233
|
+
...(user.emails !== undefined ? { emails: user.emails } : {}),
|
|
234
|
+
...(user.phoneNumbers !== undefined
|
|
235
|
+
? { phoneNumbers: user.phoneNumbers }
|
|
236
|
+
: {}),
|
|
237
|
+
active: user.active,
|
|
238
|
+
...(user.enterprise !== undefined
|
|
239
|
+
? { [SCIM_ENTERPRISE_SCHEMA]: user.enterprise }
|
|
240
|
+
: {}),
|
|
241
|
+
meta: {
|
|
242
|
+
resourceType: "User",
|
|
243
|
+
...(created !== undefined ? { created } : {}),
|
|
244
|
+
...(lastModified !== undefined ? { lastModified } : {}),
|
|
245
|
+
location: `${baseUrl}/Users/${encodeURIComponent(user.id)}`,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** RFC 7644 §3.4.2 ListResponse. */
|
|
251
|
+
export function serializeList(
|
|
252
|
+
users: ScimUserRecord[],
|
|
253
|
+
totalResults: number,
|
|
254
|
+
startIndex: number,
|
|
255
|
+
baseUrl: string,
|
|
256
|
+
): Record<string, unknown> {
|
|
257
|
+
return {
|
|
258
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
259
|
+
totalResults,
|
|
260
|
+
startIndex,
|
|
261
|
+
itemsPerPage: users.length,
|
|
262
|
+
// Capital R — the spec's own casing, and a frequent interop bug.
|
|
263
|
+
Resources: users.map((u) => serializeUser(u, baseUrl)),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseMembers(v: unknown): ScimGroupMember[] | undefined {
|
|
268
|
+
// Some IdPs send a bare object where the spec wants an array.
|
|
269
|
+
const list = Array.isArray(v) ? v : isRecord(v) ? [v] : undefined
|
|
270
|
+
if (list === undefined) return undefined
|
|
271
|
+
const out: ScimGroupMember[] = []
|
|
272
|
+
for (const entry of list) {
|
|
273
|
+
if (!isRecord(entry)) continue
|
|
274
|
+
const value = asString(entry["value"])
|
|
275
|
+
if (value === undefined) continue
|
|
276
|
+
const display = asString(entry["display"])
|
|
277
|
+
out.push({ value, ...(display !== undefined ? { display } : {}) })
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export { parseMembers }
|
|
283
|
+
|
|
284
|
+
/** Validate a `POST` / `PUT` group payload. */
|
|
285
|
+
export function parseGroupWrite(
|
|
286
|
+
body: unknown,
|
|
287
|
+
): Result<ScimGroupWrite, ScimValidationError> {
|
|
288
|
+
if (!isRecord(body)) {
|
|
289
|
+
return err({
|
|
290
|
+
status: 400,
|
|
291
|
+
scimType: "invalidSyntax",
|
|
292
|
+
detail: "request body must be a JSON object",
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
const displayName = asString(body["displayName"])
|
|
296
|
+
if (displayName === undefined) {
|
|
297
|
+
return err({
|
|
298
|
+
status: 400,
|
|
299
|
+
scimType: "invalidValue",
|
|
300
|
+
detail: "displayName is required and must be a non-empty string",
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
const externalId = asString(body["externalId"])
|
|
304
|
+
const members = parseMembers(body["members"])
|
|
305
|
+
return ok({
|
|
306
|
+
displayName,
|
|
307
|
+
...(externalId !== undefined ? { externalId } : {}),
|
|
308
|
+
...(members !== undefined ? { members } : {}),
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Serialize a host group record into a SCIM Group resource. */
|
|
313
|
+
export function serializeGroup(
|
|
314
|
+
group: ScimGroupRecord,
|
|
315
|
+
baseUrl: string,
|
|
316
|
+
): Record<string, unknown> {
|
|
317
|
+
const created = iso(group.createdAt)
|
|
318
|
+
const lastModified = iso(group.updatedAt)
|
|
319
|
+
return {
|
|
320
|
+
schemas: [SCIM_GROUP_SCHEMA],
|
|
321
|
+
id: group.id,
|
|
322
|
+
...(group.externalId !== undefined ? { externalId: group.externalId } : {}),
|
|
323
|
+
displayName: group.displayName,
|
|
324
|
+
// Omitted entirely when the host did not load membership — `[]`
|
|
325
|
+
// would tell the client the group has been emptied.
|
|
326
|
+
...(group.members !== undefined
|
|
327
|
+
? {
|
|
328
|
+
members: group.members.map((m) => ({
|
|
329
|
+
value: m.value,
|
|
330
|
+
...(m.display !== undefined ? { display: m.display } : {}),
|
|
331
|
+
$ref: `${baseUrl}/Users/${encodeURIComponent(m.value)}`,
|
|
332
|
+
type: "User",
|
|
333
|
+
})),
|
|
334
|
+
}
|
|
335
|
+
: {}),
|
|
336
|
+
meta: {
|
|
337
|
+
resourceType: "Group",
|
|
338
|
+
...(created !== undefined ? { created } : {}),
|
|
339
|
+
...(lastModified !== undefined ? { lastModified } : {}),
|
|
340
|
+
location: `${baseUrl}/Groups/${encodeURIComponent(group.id)}`,
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function serializeGroupList(
|
|
346
|
+
groups: ScimGroupRecord[],
|
|
347
|
+
totalResults: number,
|
|
348
|
+
startIndex: number,
|
|
349
|
+
baseUrl: string,
|
|
350
|
+
): Record<string, unknown> {
|
|
351
|
+
return {
|
|
352
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
353
|
+
totalResults,
|
|
354
|
+
startIndex,
|
|
355
|
+
itemsPerPage: groups.length,
|
|
356
|
+
Resources: groups.map((g) => serializeGroup(g, baseUrl)),
|
|
357
|
+
}
|
|
358
|
+
}
|
package/src/http/context.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { AuditLog } from "../ports/audit-log"
|
|
|
11
11
|
import type { ConfigStore } from "../ports/config-store"
|
|
12
12
|
import type { KeyStore } from "../ports/key-store"
|
|
13
13
|
import type { MethodStore } from "../ports/method-store"
|
|
14
|
+
import type { ScimDirectory } from "../ports/scim-directory"
|
|
14
15
|
import type { SessionStore } from "../ports/session-store"
|
|
15
16
|
import type { TokenStore } from "../ports/token-store"
|
|
16
17
|
import type { MethodCache } from "../domain/method-cache"
|
|
@@ -43,6 +44,8 @@ export type HttpDeps = {
|
|
|
43
44
|
keyStore: KeyStore
|
|
44
45
|
methodStore?: MethodStore
|
|
45
46
|
auditLog?: AuditLog
|
|
47
|
+
/** See `IdPOptions.scimDirectory`. Absent ⇒ `/scim/v2/*` answers 501. */
|
|
48
|
+
scimDirectory?: ScimDirectory
|
|
46
49
|
methodCache: MethodCache
|
|
47
50
|
stateKeys: StateKeyRing
|
|
48
51
|
/** Resolves the issuer URL — string or per-request function. */
|
|
@@ -45,10 +45,7 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
|
|
|
45
45
|
const httpMethod =
|
|
46
46
|
c.req.method.toUpperCase() === "POST" ? "POST" : "GET"
|
|
47
47
|
const routeKey = `${httpMethod} ${subPath}` as RouteKey
|
|
48
|
-
const resolved = await deps.methodCache.resolve(
|
|
49
|
-
tenant.config,
|
|
50
|
-
methodId,
|
|
51
|
-
)
|
|
48
|
+
const resolved = await deps.methodCache.resolve(tenant.config, methodId)
|
|
52
49
|
if (
|
|
53
50
|
!isErr(resolved) &&
|
|
54
51
|
resolved.value.publicRoutes?.includes(routeKey)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/scim/v2/*` — SCIM 2.0 provisioning.
|
|
3
|
+
*
|
|
4
|
+
* Thin by design: parse the request into plain data, hand it to
|
|
5
|
+
* `handleScimRequest`, serialize the result. Every decision — auth,
|
|
6
|
+
* routing, validation, status codes — lives in the domain, so the whole
|
|
7
|
+
* SCIM surface is testable without Hono.
|
|
8
|
+
*
|
|
9
|
+
* Responses carry `application/scim+json` per RFC 7644 §3.1.
|
|
10
|
+
*/
|
|
11
|
+
import { handleScimRequest } from "../../domain/scim/handle"
|
|
12
|
+
import { scimErrorBody } from "../../domain/scim/resource"
|
|
13
|
+
|
|
14
|
+
import type { HttpContext, HttpDeps } from "../context"
|
|
15
|
+
|
|
16
|
+
const SCIM_CONTENT_TYPE = "application/scim+json;charset=utf-8"
|
|
17
|
+
const MOUNT = "/scim/v2"
|
|
18
|
+
|
|
19
|
+
function scimResponse(
|
|
20
|
+
status: number,
|
|
21
|
+
body: Record<string, unknown> | null,
|
|
22
|
+
extra: Record<string, string> = {},
|
|
23
|
+
): Response {
|
|
24
|
+
if (body === null || status === 204) {
|
|
25
|
+
return new Response(null, { status, headers: extra })
|
|
26
|
+
}
|
|
27
|
+
return new Response(JSON.stringify(body), {
|
|
28
|
+
status,
|
|
29
|
+
headers: {
|
|
30
|
+
"content-type": SCIM_CONTENT_TYPE,
|
|
31
|
+
// Provisioning responses are per-tenant and mutable; never cached.
|
|
32
|
+
"cache-control": "no-store",
|
|
33
|
+
...extra,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function makeScimHandler(deps: HttpDeps) {
|
|
39
|
+
return async (c: HttpContext): Promise<Response> => {
|
|
40
|
+
if (!deps.scimDirectory) {
|
|
41
|
+
return scimResponse(
|
|
42
|
+
501,
|
|
43
|
+
scimErrorBody(
|
|
44
|
+
501,
|
|
45
|
+
"SCIM is not configured on this deployment (no scimDirectory " +
|
|
46
|
+
"was supplied to createIdP)",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tenant = c.get("tenant")
|
|
52
|
+
if (!tenant) {
|
|
53
|
+
// The tenant middleware could not resolve a partition. Answer the
|
|
54
|
+
// same way an unknown token does, so this endpoint never reveals
|
|
55
|
+
// which tenants exist.
|
|
56
|
+
return scimResponse(
|
|
57
|
+
403,
|
|
58
|
+
scimErrorBody(403, "SCIM provisioning is not enabled for this tenant"),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const url = new URL(c.req.url)
|
|
63
|
+
const path = url.pathname.startsWith(MOUNT)
|
|
64
|
+
? url.pathname.slice(MOUNT.length)
|
|
65
|
+
: url.pathname
|
|
66
|
+
|
|
67
|
+
const method = c.req.method.toUpperCase()
|
|
68
|
+
|
|
69
|
+
// Only read a body for methods that carry one. An unparseable body
|
|
70
|
+
// becomes `null` and the domain reports it as invalid syntax with
|
|
71
|
+
// the right SCIM envelope, rather than throwing here.
|
|
72
|
+
let body: unknown = null
|
|
73
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
74
|
+
const raw = await c.req.raw.text()
|
|
75
|
+
if (raw.length > 0) {
|
|
76
|
+
try {
|
|
77
|
+
body = JSON.parse(raw)
|
|
78
|
+
} catch {
|
|
79
|
+
return scimResponse(
|
|
80
|
+
400,
|
|
81
|
+
scimErrorBody(
|
|
82
|
+
400,
|
|
83
|
+
"request body is not valid JSON",
|
|
84
|
+
"invalidSyntax",
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const issuer = c.get("issuerUrl")
|
|
92
|
+
const base = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer
|
|
93
|
+
|
|
94
|
+
const result = await handleScimRequest({
|
|
95
|
+
tenant,
|
|
96
|
+
method,
|
|
97
|
+
path,
|
|
98
|
+
query: url.searchParams,
|
|
99
|
+
body,
|
|
100
|
+
authorization: c.req.header("authorization") ?? null,
|
|
101
|
+
baseUrl: `${base}${MOUNT}`,
|
|
102
|
+
directory: deps.scimDirectory,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return scimResponse(result.status, result.body, result.headers ?? {})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -32,7 +32,47 @@ import {
|
|
|
32
32
|
authorizeDirectErrorResponse,
|
|
33
33
|
tokenEndpointErrorResponse,
|
|
34
34
|
} from "../errors"
|
|
35
|
-
import { authError } from "../../types/error"
|
|
35
|
+
import { authError, type AuthError } from "../../types/error"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SCIM has its own media type and error envelope, and its own
|
|
39
|
+
* disclosure rule: an unauthenticated caller must not be able to tell an
|
|
40
|
+
* unknown tenant from a known one. The shared OAuth error response
|
|
41
|
+
* defeats both — it answers `400 application/json` with the
|
|
42
|
+
* tenant-not-found text, which is visibly different from the `403` a
|
|
43
|
+
* configured-but-SCIM-disabled tenant gets.
|
|
44
|
+
*
|
|
45
|
+
* So SCIM paths collapse every tenant-resolution failure onto the same
|
|
46
|
+
* `403` the domain layer emits for "SCIM is not enabled here". A store
|
|
47
|
+
* failure is still distinguishable as a 500, because that is an
|
|
48
|
+
* operational fault rather than a fact about which tenants exist.
|
|
49
|
+
*/
|
|
50
|
+
function isScimPath(pathname: string): boolean {
|
|
51
|
+
return pathname === "/scim/v2" || pathname.startsWith("/scim/v2/")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scimTenantErrorResponse(error: AuthError): Response {
|
|
55
|
+
const operational =
|
|
56
|
+
error.code === "server_error" || error.code === "internal_error"
|
|
57
|
+
const status = operational ? 500 : 403
|
|
58
|
+
const detail = operational
|
|
59
|
+
? "the configuration store backing this endpoint failed the request"
|
|
60
|
+
: "SCIM provisioning is not enabled for this tenant"
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
64
|
+
status: String(status),
|
|
65
|
+
detail,
|
|
66
|
+
}),
|
|
67
|
+
{
|
|
68
|
+
status,
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/scim+json;charset=utf-8",
|
|
71
|
+
"cache-control": "no-store",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
}
|
|
36
76
|
|
|
37
77
|
function isCallbackPath(pathname: string): boolean {
|
|
38
78
|
return pathname.startsWith("/cb/")
|
|
@@ -89,6 +129,9 @@ export function tenantMiddleware(deps: HttpDeps): MiddlewareHandler<HttpEnv> {
|
|
|
89
129
|
if (url.pathname === "/authorize") {
|
|
90
130
|
return authorizeDirectErrorResponse(resolved.error)
|
|
91
131
|
}
|
|
132
|
+
if (isScimPath(url.pathname)) {
|
|
133
|
+
return scimTenantErrorResponse(resolved.error)
|
|
134
|
+
}
|
|
92
135
|
return tokenEndpointErrorResponse(resolved.error)
|
|
93
136
|
}
|
|
94
137
|
const tenantId = resolved.value
|
|
@@ -101,6 +144,9 @@ export function tenantMiddleware(deps: HttpDeps): MiddlewareHandler<HttpEnv> {
|
|
|
101
144
|
if (url.pathname === "/authorize") {
|
|
102
145
|
return authorizeDirectErrorResponse(err)
|
|
103
146
|
}
|
|
147
|
+
if (isScimPath(url.pathname)) {
|
|
148
|
+
return scimTenantErrorResponse(cfg.error)
|
|
149
|
+
}
|
|
104
150
|
return tokenEndpointErrorResponse(err)
|
|
105
151
|
}
|
|
106
152
|
const custom = deps.buildCustomContext
|
package/src/http/router.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { makeDiscoveryHandler, makeJwksHandler } from "./handlers/metadata"
|
|
|
24
24
|
import { makeMethodRouteHandler } from "./handlers/method-route"
|
|
25
25
|
import { makeParHandler } from "./handlers/par"
|
|
26
26
|
import { makeRegisterHandler } from "./handlers/register"
|
|
27
|
+
import { makeScimHandler } from "./handlers/scim"
|
|
27
28
|
import { makeIntrospectHandler, makeRevokeHandler } from "./handlers/revocation"
|
|
28
29
|
import { makeTokenHandler } from "./handlers/token"
|
|
29
30
|
import { makeUserinfoHandler } from "./handlers/userinfo"
|
|
@@ -71,6 +72,14 @@ export function buildRouter(deps: HttpDeps): Hono<HttpEnv> {
|
|
|
71
72
|
app.get("/end_session", makeEndSessionHandler(deps))
|
|
72
73
|
app.post("/end_session", makeEndSessionHandler(deps))
|
|
73
74
|
|
|
75
|
+
// SCIM 2.0 provisioning. Tenant resolved by the standard middleware;
|
|
76
|
+
// the per-connection bearer token is then verified against that
|
|
77
|
+
// tenant's config in the domain layer (SCIM-AD4). A single `all`
|
|
78
|
+
// mount keeps Hono out of SCIM's routing, which lives in
|
|
79
|
+
// `domain/scim/handle.ts`.
|
|
80
|
+
app.use("/scim/v2/*", tenantMiddleware(deps))
|
|
81
|
+
app.all("/scim/v2/*", makeScimHandler(deps))
|
|
82
|
+
|
|
74
83
|
// RFC 9126 Pushed Authorization Requests.
|
|
75
84
|
app.use("/par", tenantMiddleware(deps))
|
|
76
85
|
app.post("/par", makeParHandler(deps))
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,25 @@ export type {
|
|
|
98
98
|
SigningKey,
|
|
99
99
|
} from "./ports/key-store"
|
|
100
100
|
export type { MethodStore } from "./ports/method-store"
|
|
101
|
+
export type { ScimDirectory } from "./ports/scim-directory"
|
|
102
|
+
export type {
|
|
103
|
+
ScimConfig,
|
|
104
|
+
ScimEnterpriseUser,
|
|
105
|
+
ScimGroupMember,
|
|
106
|
+
ScimGroupPatch,
|
|
107
|
+
ScimGroupQuery,
|
|
108
|
+
ScimGroupRecord,
|
|
109
|
+
ScimGroupWrite,
|
|
110
|
+
ScimFilter,
|
|
111
|
+
ScimFilterAttribute,
|
|
112
|
+
ScimMultiValue,
|
|
113
|
+
ScimName,
|
|
114
|
+
ScimPage,
|
|
115
|
+
ScimUserPatch,
|
|
116
|
+
ScimUserQuery,
|
|
117
|
+
ScimUserRecord,
|
|
118
|
+
ScimUserWrite,
|
|
119
|
+
} from "./types/scim"
|
|
101
120
|
export type {
|
|
102
121
|
ParRecord,
|
|
103
122
|
SessionRecord,
|
|
@@ -241,6 +260,7 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
241
260
|
sessionStore: opts.sessionStore,
|
|
242
261
|
keyStore: opts.keyStore,
|
|
243
262
|
...(opts.methodStore ? { methodStore: opts.methodStore } : {}),
|
|
263
|
+
...(opts.scimDirectory ? { scimDirectory: opts.scimDirectory } : {}),
|
|
244
264
|
...(auditLog ? { auditLog } : {}),
|
|
245
265
|
methodCache,
|
|
246
266
|
stateKeys: opts.stateKeys,
|