@_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.
Files changed (95) hide show
  1. package/dist/esm/domain/scim/discovery.js +206 -0
  2. package/dist/esm/domain/scim/filter.js +177 -0
  3. package/dist/esm/domain/scim/handle.js +338 -0
  4. package/dist/esm/domain/scim/patch.js +568 -0
  5. package/dist/esm/domain/scim/resource.js +256 -0
  6. package/dist/esm/http/handlers/scim.js +59 -0
  7. package/dist/esm/http/middleware/tenant.js +25 -0
  8. package/dist/esm/http/router.js +3 -0
  9. package/dist/esm/index.js +1 -0
  10. package/dist/esm/methods/saml-sp/acs.js +44 -20
  11. package/dist/esm/methods/saml-sp/attributes.js +2 -0
  12. package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
  13. package/dist/esm/methods/saml-sp/factory.js +12 -1
  14. package/dist/esm/methods/saml-sp/metadata.js +5 -3
  15. package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
  16. package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
  17. package/dist/esm/methods/saml-sp/sls.js +2 -2
  18. package/dist/esm/ports/scim-directory.js +0 -0
  19. package/dist/esm/types/error.js +5 -0
  20. package/dist/esm/types/scim.js +0 -0
  21. package/dist/types/domain/scim/discovery.d.ts +11 -0
  22. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  23. package/dist/types/domain/scim/filter.d.ts +51 -0
  24. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  25. package/dist/types/domain/scim/handle.d.ts +35 -0
  26. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  27. package/dist/types/domain/scim/patch.d.ts +73 -0
  28. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  29. package/dist/types/domain/scim/resource.d.ts +56 -0
  30. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  31. package/dist/types/http/context.d.ts +3 -0
  32. package/dist/types/http/context.d.ts.map +1 -1
  33. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  34. package/dist/types/http/handlers/scim.d.ts +3 -0
  35. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  36. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  37. package/dist/types/http/router.d.ts.map +1 -1
  38. package/dist/types/index.d.ts +2 -0
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
  41. package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
  42. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
  43. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
  44. package/dist/types/methods/saml-sp/factory.d.ts +217 -1
  45. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
  46. package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
  47. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
  48. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
  49. package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
  50. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
  51. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
  52. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
  53. package/dist/types/methods/saml-sp/types.d.ts +95 -0
  54. package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
  55. package/dist/types/ports/scim-directory.d.ts +104 -0
  56. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  57. package/dist/types/ports/session-store.d.ts.map +1 -1
  58. package/dist/types/types/error.d.ts +5 -0
  59. package/dist/types/types/error.d.ts.map +1 -1
  60. package/dist/types/types/idp.d.ts +12 -0
  61. package/dist/types/types/idp.d.ts.map +1 -1
  62. package/dist/types/types/scim.d.ts +269 -0
  63. package/dist/types/types/scim.d.ts.map +1 -0
  64. package/dist/types/types/tenant.d.ts +10 -0
  65. package/dist/types/types/tenant.d.ts.map +1 -1
  66. package/package.json +1 -1
  67. package/src/domain/scim/discovery.ts +262 -0
  68. package/src/domain/scim/filter.ts +295 -0
  69. package/src/domain/scim/handle.ts +597 -0
  70. package/src/domain/scim/patch.ts +807 -0
  71. package/src/domain/scim/resource.ts +358 -0
  72. package/src/http/context.ts +3 -0
  73. package/src/http/handlers/authorize.ts +0 -1
  74. package/src/http/handlers/method-route.ts +1 -4
  75. package/src/http/handlers/scim.ts +107 -0
  76. package/src/http/middleware/tenant.ts +47 -1
  77. package/src/http/router.ts +9 -0
  78. package/src/index.ts +20 -0
  79. package/src/methods/saml-sp/acs.ts +99 -49
  80. package/src/methods/saml-sp/attributes.ts +12 -3
  81. package/src/methods/saml-sp/authnrequest.ts +3 -2
  82. package/src/methods/saml-sp/factory.ts +24 -3
  83. package/src/methods/saml-sp/metadata.ts +19 -8
  84. package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
  85. package/src/methods/saml-sp/saml-instance.ts +42 -5
  86. package/src/methods/saml-sp/slo-initiate.ts +6 -3
  87. package/src/methods/saml-sp/sls.ts +3 -2
  88. package/src/methods/saml-sp/types.ts +95 -0
  89. package/src/ports/CONSISTENCY.md +28 -25
  90. package/src/ports/scim-directory.ts +166 -0
  91. package/src/ports/session-store.ts +1 -5
  92. package/src/types/error.ts +11 -0
  93. package/src/types/idp.ts +12 -0
  94. package/src/types/scim.ts +280 -0
  95. package/src/types/tenant.ts +10 -0
@@ -0,0 +1,338 @@
1
+ // src/domain/scim/handle.ts
2
+ import { timingSafeEqualStr } from "../crypto";
3
+ import { hashClientSecret } from "../token";
4
+ import { resourceTypes, schemas, serviceProviderConfig } from "./discovery";
5
+ import {
6
+ GROUP_FILTER_ATTRIBUTES,
7
+ parseScimFilter,
8
+ SUPPORTED_FILTER_HELP,
9
+ USER_FILTER_ATTRIBUTES
10
+ } from "./filter";
11
+ import { normalizeGroupPatch, normalizePatch } from "./patch";
12
+ import {
13
+ parseGroupWrite,
14
+ parseUserWrite,
15
+ scimErrorBody,
16
+ serializeGroup,
17
+ serializeGroupList,
18
+ serializeList,
19
+ serializeUser
20
+ } from "./resource";
21
+ var DEFAULT_PAGE_SIZE = 100;
22
+ var PAGE_SIZE_CEILING = 500;
23
+ function created(body) {
24
+ const location = body["meta"]?.["location"];
25
+ return {
26
+ status: 201,
27
+ body,
28
+ ...typeof location === "string" ? { headers: { location } } : {}
29
+ };
30
+ }
31
+ var fail = (status, detail, scimType) => ({
32
+ status,
33
+ body: scimErrorBody(status, detail, scimType)
34
+ });
35
+ function fromPortError(error) {
36
+ if (error.code === "conflict") {
37
+ return fail(409, error.description, "uniqueness");
38
+ }
39
+ if (error.code === "invalid_request") {
40
+ return fail(400, error.description, "invalidValue");
41
+ }
42
+ return fail(500, "the directory backing this endpoint failed the request");
43
+ }
44
+ function unwrap(r) {
45
+ if (r.ok)
46
+ return { value: r.value };
47
+ return { response: fromPortError(r.error) };
48
+ }
49
+ async function authenticate(scim, authorization) {
50
+ if (!scim || scim.enabled !== true) {
51
+ return fail(403, "SCIM provisioning is not enabled for this tenant");
52
+ }
53
+ const prefix = "bearer ";
54
+ if (authorization === null || !authorization.toLowerCase().startsWith(prefix)) {
55
+ return fail(401, "a Bearer token is required");
56
+ }
57
+ const presented = authorization.slice(prefix.length).trim();
58
+ if (presented.length === 0)
59
+ return fail(401, "a Bearer token is required");
60
+ const hashed = await hashClientSecret(presented);
61
+ if (!timingSafeEqualStr(hashed, scim.tokenHash)) {
62
+ return fail(401, "invalid Bearer token");
63
+ }
64
+ return null;
65
+ }
66
+ function userIdFrom(path) {
67
+ const m = /^\/Users\/(.+)$/.exec(path);
68
+ if (!m)
69
+ return null;
70
+ try {
71
+ return decodeURIComponent(m[1]);
72
+ } catch {
73
+ return m[1];
74
+ }
75
+ }
76
+ function clampPaging(query, maxPageSize) {
77
+ const rawStart = Number(query.get("startIndex") ?? "1");
78
+ const startIndex = Number.isFinite(rawStart) && rawStart >= 1 ? Math.floor(rawStart) : 1;
79
+ const rawCount = query.get("count");
80
+ if (rawCount === null)
81
+ return { startIndex, count: maxPageSize };
82
+ const parsed = Number(rawCount);
83
+ if (!Number.isFinite(parsed))
84
+ return { startIndex, count: maxPageSize };
85
+ if (parsed < 0)
86
+ return { startIndex, count: 0 };
87
+ return { startIndex, count: Math.min(Math.floor(parsed), maxPageSize) };
88
+ }
89
+ async function listUsers(input, maxPageSize) {
90
+ const filter = parseScimFilter(input.query.get("filter"), USER_FILTER_ATTRIBUTES);
91
+ if (!filter.ok) {
92
+ return fail(400, filter.error.detail, "invalidFilter");
93
+ }
94
+ const { startIndex, count } = clampPaging(input.query, maxPageSize);
95
+ const page = await input.directory.findUsers(input.tenant.id, {
96
+ ...filter.value !== undefined ? { filter: filter.value } : {},
97
+ startIndex,
98
+ count
99
+ });
100
+ const r = unwrap(page);
101
+ if ("response" in r)
102
+ return r.response;
103
+ return {
104
+ status: 200,
105
+ body: serializeList(r.value.resources, r.value.totalResults, startIndex, input.baseUrl)
106
+ };
107
+ }
108
+ async function createUser(input) {
109
+ const parsed = parseUserWrite(input.body);
110
+ if (!parsed.ok) {
111
+ return fail(parsed.error.status, parsed.error.detail, parsed.error.scimType);
112
+ }
113
+ const created_ = await input.directory.createUser(input.tenant.id, parsed.value);
114
+ const r = unwrap(created_);
115
+ if ("response" in r)
116
+ return r.response;
117
+ return created(serializeUser(r.value, input.baseUrl));
118
+ }
119
+ async function loadUser(input, id) {
120
+ const found = await input.directory.getUser(input.tenant.id, id);
121
+ const r = unwrap(found);
122
+ if ("response" in r)
123
+ return r;
124
+ if (r.value === null) {
125
+ return { response: fail(404, `no User with id "${id}"`) };
126
+ }
127
+ return { user: r.value };
128
+ }
129
+ async function replaceUser(input, id) {
130
+ const existing = await loadUser(input, id);
131
+ if ("response" in existing)
132
+ return existing.response;
133
+ const parsed = parseUserWrite(input.body);
134
+ if (!parsed.ok) {
135
+ return fail(parsed.error.status, parsed.error.detail, parsed.error.scimType);
136
+ }
137
+ const write = parsed.value;
138
+ const replaced = await input.directory.replaceUser(input.tenant.id, id, write);
139
+ const r = unwrap(replaced);
140
+ if ("response" in r)
141
+ return r.response;
142
+ return { status: 200, body: serializeUser(r.value, input.baseUrl) };
143
+ }
144
+ async function patchUser(input, id) {
145
+ const existing = await loadUser(input, id);
146
+ if ("response" in existing)
147
+ return existing.response;
148
+ const normalized = normalizePatch(input.body, existing.user);
149
+ if (!normalized.ok) {
150
+ return fail(normalized.error.status, normalized.error.detail, normalized.error.scimType);
151
+ }
152
+ const patched = await input.directory.patchUser(input.tenant.id, id, normalized.value);
153
+ const r = unwrap(patched);
154
+ if ("response" in r)
155
+ return r.response;
156
+ return { status: 200, body: serializeUser(r.value, input.baseUrl) };
157
+ }
158
+ async function deleteUser(input, id) {
159
+ const existing = await loadUser(input, id);
160
+ if ("response" in existing)
161
+ return existing.response;
162
+ const deleted = await input.directory.deleteUser(input.tenant.id, id);
163
+ const r = unwrap(deleted);
164
+ if ("response" in r)
165
+ return r.response;
166
+ return { status: 204, body: null };
167
+ }
168
+ function groupsSupported(d) {
169
+ return typeof d.getGroup === "function" && typeof d.findGroups === "function" && typeof d.createGroup === "function" && typeof d.replaceGroup === "function" && typeof d.patchGroup === "function" && typeof d.deleteGroup === "function";
170
+ }
171
+ function excludesMembers(query) {
172
+ const raw = query.get("excludedAttributes");
173
+ if (raw === null)
174
+ return false;
175
+ return raw.split(",").map((s) => s.trim().toLowerCase()).includes("members");
176
+ }
177
+ async function loadGroup(input, groups, id) {
178
+ const found = await groups.getGroup(input.tenant.id, id);
179
+ const r = unwrap(found);
180
+ if ("response" in r)
181
+ return r;
182
+ if (r.value === null) {
183
+ return { response: fail(404, `no Group with id "${id}"`) };
184
+ }
185
+ return { group: r.value };
186
+ }
187
+ async function handleGroups(input, groups, path, method, maxPageSize) {
188
+ if (path === "/Groups" || path === "/Groups/") {
189
+ if (method === "GET") {
190
+ const filter = parseScimFilter(input.query.get("filter"), GROUP_FILTER_ATTRIBUTES);
191
+ if (!filter.ok)
192
+ return fail(400, filter.error.detail, "invalidFilter");
193
+ const { startIndex, count } = clampPaging(input.query, maxPageSize);
194
+ const page = await groups.findGroups(input.tenant.id, {
195
+ ...filter.value !== undefined ? { filter: filter.value } : {},
196
+ startIndex,
197
+ count,
198
+ excludeMembers: excludesMembers(input.query)
199
+ });
200
+ const r = unwrap(page);
201
+ if ("response" in r)
202
+ return r.response;
203
+ return {
204
+ status: 200,
205
+ body: serializeGroupList(r.value.resources, r.value.totalResults, startIndex, input.baseUrl)
206
+ };
207
+ }
208
+ if (method === "POST") {
209
+ const parsed = parseGroupWrite(input.body);
210
+ if (!parsed.ok) {
211
+ return fail(parsed.error.status, parsed.error.detail, parsed.error.scimType);
212
+ }
213
+ const createdGroup = await groups.createGroup(input.tenant.id, parsed.value);
214
+ const r = unwrap(createdGroup);
215
+ if ("response" in r)
216
+ return r.response;
217
+ return created(serializeGroup(r.value, input.baseUrl));
218
+ }
219
+ return fail(405, `${method} is not allowed on /Groups`);
220
+ }
221
+ const m = /^\/Groups\/(.+)$/.exec(path);
222
+ if (!m)
223
+ return fail(404, `unknown SCIM endpoint "${path}"`);
224
+ let id;
225
+ try {
226
+ id = decodeURIComponent(m[1]);
227
+ } catch {
228
+ id = m[1];
229
+ }
230
+ switch (method) {
231
+ case "GET": {
232
+ const existing = await loadGroup(input, groups, id);
233
+ if ("response" in existing)
234
+ return existing.response;
235
+ return {
236
+ status: 200,
237
+ body: serializeGroup(existing.group, input.baseUrl)
238
+ };
239
+ }
240
+ case "PUT": {
241
+ const existing = await loadGroup(input, groups, id);
242
+ if ("response" in existing)
243
+ return existing.response;
244
+ const parsed = parseGroupWrite(input.body);
245
+ if (!parsed.ok) {
246
+ return fail(parsed.error.status, parsed.error.detail, parsed.error.scimType);
247
+ }
248
+ const replaced = await groups.replaceGroup(input.tenant.id, id, parsed.value);
249
+ const r = unwrap(replaced);
250
+ if ("response" in r)
251
+ return r.response;
252
+ return { status: 200, body: serializeGroup(r.value, input.baseUrl) };
253
+ }
254
+ case "PATCH": {
255
+ const existing = await loadGroup(input, groups, id);
256
+ if ("response" in existing)
257
+ return existing.response;
258
+ const normalized = normalizeGroupPatch(input.body);
259
+ if (!normalized.ok) {
260
+ return fail(normalized.error.status, normalized.error.detail, normalized.error.scimType);
261
+ }
262
+ const patched = await groups.patchGroup(input.tenant.id, id, normalized.value);
263
+ const r = unwrap(patched);
264
+ if ("response" in r)
265
+ return r.response;
266
+ return { status: 200, body: serializeGroup(r.value, input.baseUrl) };
267
+ }
268
+ case "DELETE": {
269
+ const existing = await loadGroup(input, groups, id);
270
+ if ("response" in existing)
271
+ return existing.response;
272
+ const deleted = await groups.deleteGroup(input.tenant.id, id);
273
+ const r = unwrap(deleted);
274
+ if ("response" in r)
275
+ return r.response;
276
+ return { status: 204, body: null };
277
+ }
278
+ default:
279
+ return fail(405, `${method} is not allowed on /Groups/{id}`);
280
+ }
281
+ }
282
+ async function handleScimRequest(input) {
283
+ const scim = input.tenant.config.scim;
284
+ const authFailure = await authenticate(scim, input.authorization);
285
+ if (authFailure)
286
+ return authFailure;
287
+ const maxPageSize = Math.min(scim?.maxPageSize ?? DEFAULT_PAGE_SIZE, PAGE_SIZE_CEILING);
288
+ const path = input.path === "" ? "/" : input.path;
289
+ const method = input.method.toUpperCase();
290
+ if (path === "/ServiceProviderConfig" || path === "/ResourceTypes" || path === "/Schemas") {
291
+ if (method !== "GET") {
292
+ return fail(405, `${method} is not allowed on ${path}`);
293
+ }
294
+ const groups = groupsSupported(input.directory);
295
+ const body = path === "/ServiceProviderConfig" ? serviceProviderConfig(input.baseUrl, maxPageSize) : path === "/ResourceTypes" ? resourceTypes(input.baseUrl, groups) : schemas(input.baseUrl, groups);
296
+ return { status: 200, body };
297
+ }
298
+ if (path === "/Users" || path === "/Users/") {
299
+ if (method === "GET")
300
+ return listUsers(input, maxPageSize);
301
+ if (method === "POST")
302
+ return createUser(input);
303
+ return fail(405, `${method} is not allowed on /Users`);
304
+ }
305
+ const id = userIdFrom(path);
306
+ if (id !== null) {
307
+ switch (method) {
308
+ case "GET": {
309
+ const existing = await loadUser(input, id);
310
+ if ("response" in existing)
311
+ return existing.response;
312
+ return {
313
+ status: 200,
314
+ body: serializeUser(existing.user, input.baseUrl)
315
+ };
316
+ }
317
+ case "PUT":
318
+ return replaceUser(input, id);
319
+ case "PATCH":
320
+ return patchUser(input, id);
321
+ case "DELETE":
322
+ return deleteUser(input, id);
323
+ default:
324
+ return fail(405, `${method} is not allowed on /Users/{id}`);
325
+ }
326
+ }
327
+ if (path.startsWith("/Groups")) {
328
+ if (!groupsSupported(input.directory)) {
329
+ return fail(501, "Group provisioning is not implemented by this Service Provider");
330
+ }
331
+ return handleGroups(input, input.directory, path, method, maxPageSize);
332
+ }
333
+ return fail(404, `unknown SCIM endpoint "${path}"`);
334
+ }
335
+ export {
336
+ SUPPORTED_FILTER_HELP,
337
+ handleScimRequest
338
+ };