@_mustachio/openauth 0.11.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 (208) hide show
  1. package/dist/esm/adapters/d1/index.js +18 -9
  2. package/dist/esm/adapters/d1/migrations.js +7 -2
  3. package/dist/esm/adapters/d1/session-store.js +39 -0
  4. package/dist/esm/adapters/d1/session.js +2 -2
  5. package/dist/esm/adapters/durable-object/index.js +4 -0
  6. package/dist/esm/adapters/durable-object/session-store.js +42 -0
  7. package/dist/esm/adapters/dynamo/index.js +24 -7
  8. package/dist/esm/adapters/dynamo/session-store.js +49 -0
  9. package/dist/esm/adapters/kms/index.js +6 -2
  10. package/dist/esm/adapters/kv/index.js +6 -2
  11. package/dist/esm/adapters/memory/index.js +24 -7
  12. package/dist/esm/adapters/memory/session-store.js +23 -0
  13. package/dist/esm/adapters/postgres/executor.js +2 -2
  14. package/dist/esm/adapters/postgres/index.js +33 -10
  15. package/dist/esm/adapters/postgres/migrations.js +8 -2
  16. package/dist/esm/adapters/postgres/session-store.js +42 -0
  17. package/dist/esm/client.js +27 -27
  18. package/dist/esm/domain/authorize.js +2 -2
  19. package/dist/esm/domain/callback.js +121 -7
  20. package/dist/esm/domain/client-auth.js +2 -2
  21. package/dist/esm/domain/crypto.js +14 -14
  22. package/dist/esm/domain/discovery.js +2 -2
  23. package/dist/esm/domain/dpop.js +4 -4
  24. package/dist/esm/domain/id-token.js +5 -5
  25. package/dist/esm/domain/jwt.js +4 -4
  26. package/dist/esm/domain/method-dispatch.js +27 -1
  27. package/dist/esm/domain/method-route.js +80 -1
  28. package/dist/esm/domain/par.js +2 -2
  29. package/dist/esm/domain/pkce.js +2 -2
  30. package/dist/esm/domain/revoke.js +2 -2
  31. package/dist/esm/domain/scim/discovery.js +206 -0
  32. package/dist/esm/domain/scim/filter.js +177 -0
  33. package/dist/esm/domain/scim/handle.js +338 -0
  34. package/dist/esm/domain/scim/patch.js +568 -0
  35. package/dist/esm/domain/scim/resource.js +256 -0
  36. package/dist/esm/domain/state-envelope.js +2 -2
  37. package/dist/esm/domain/token.js +5 -5
  38. package/dist/esm/error.js +8 -8
  39. package/dist/esm/http/cookies.js +19 -2
  40. package/dist/esm/http/errors.js +4 -4
  41. package/dist/esm/http/handlers/authorize.js +3 -19
  42. package/dist/esm/http/handlers/callback.js +7 -1
  43. package/dist/esm/http/handlers/metadata.js +2 -2
  44. package/dist/esm/http/handlers/method-route.js +47 -2
  45. package/dist/esm/http/handlers/revocation.js +2 -2
  46. package/dist/esm/http/handlers/scim.js +59 -0
  47. package/dist/esm/http/middleware/tenant.js +31 -6
  48. package/dist/esm/http/router.js +3 -0
  49. package/dist/esm/http/schemas/authorize.js +2 -2
  50. package/dist/esm/http/schemas/revocation.js +2 -2
  51. package/dist/esm/http/schemas/token.js +7 -7
  52. package/dist/esm/index.js +34 -32
  53. package/dist/esm/methods/oauth2-factory.js +2 -2
  54. package/dist/esm/methods/password-hash.js +2 -2
  55. package/dist/esm/methods/providers/index.js +30 -14
  56. package/dist/esm/methods/saml-sp/acs.js +232 -0
  57. package/dist/esm/methods/saml-sp/attributes.js +77 -0
  58. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  59. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  60. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  61. package/dist/esm/methods/saml-sp/factory.js +81 -0
  62. package/dist/esm/methods/saml-sp/index.js +7 -0
  63. package/dist/esm/methods/saml-sp/metadata.js +71 -0
  64. package/dist/esm/methods/saml-sp/method.js +29 -0
  65. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  66. package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
  67. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  68. package/dist/esm/methods/saml-sp/sls.js +154 -0
  69. package/dist/esm/methods/saml-sp/types.js +0 -0
  70. package/dist/esm/ports/scim-directory.js +0 -0
  71. package/dist/esm/types/error.js +5 -0
  72. package/dist/esm/types/result.js +3 -3
  73. package/dist/esm/types/scim.js +0 -0
  74. package/dist/esm/ui/forms.js +3 -3
  75. package/dist/esm/ui/index.js +8 -5
  76. package/dist/esm/ui/picker.js +2 -2
  77. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  79. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  80. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  81. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  82. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  83. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  84. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  85. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  86. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  87. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  88. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  89. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  90. package/dist/types/domain/callback.d.ts +16 -1
  91. package/dist/types/domain/callback.d.ts.map +1 -1
  92. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  93. package/dist/types/domain/method-route.d.ts +57 -1
  94. package/dist/types/domain/method-route.d.ts.map +1 -1
  95. package/dist/types/domain/scim/discovery.d.ts +11 -0
  96. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  97. package/dist/types/domain/scim/filter.d.ts +51 -0
  98. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  99. package/dist/types/domain/scim/handle.d.ts +35 -0
  100. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  101. package/dist/types/domain/scim/patch.d.ts +73 -0
  102. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  103. package/dist/types/domain/scim/resource.d.ts +56 -0
  104. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  105. package/dist/types/http/context.d.ts +6 -1
  106. package/dist/types/http/context.d.ts.map +1 -1
  107. package/dist/types/http/cookies.d.ts +9 -1
  108. package/dist/types/http/cookies.d.ts.map +1 -1
  109. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  110. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  111. package/dist/types/http/handlers/scim.d.ts +3 -0
  112. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  113. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  114. package/dist/types/http/router.d.ts.map +1 -1
  115. package/dist/types/index.d.ts +3 -1
  116. package/dist/types/index.d.ts.map +1 -1
  117. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
  120. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  121. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  122. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  123. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  124. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  125. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  126. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  127. package/dist/types/methods/saml-sp/factory.d.ts +920 -0
  128. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  129. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  130. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  131. package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
  132. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  133. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  134. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  135. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  136. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  137. package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
  138. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  139. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  140. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  141. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  142. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  143. package/dist/types/methods/saml-sp/types.d.ts +299 -0
  144. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  145. package/dist/types/ports/audit-log.d.ts +18 -5
  146. package/dist/types/ports/audit-log.d.ts.map +1 -1
  147. package/dist/types/ports/scim-directory.d.ts +104 -0
  148. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  149. package/dist/types/ports/session-store.d.ts +23 -0
  150. package/dist/types/ports/session-store.d.ts.map +1 -1
  151. package/dist/types/types/error.d.ts +5 -0
  152. package/dist/types/types/error.d.ts.map +1 -1
  153. package/dist/types/types/idp.d.ts +83 -0
  154. package/dist/types/types/idp.d.ts.map +1 -1
  155. package/dist/types/types/method.d.ts +109 -0
  156. package/dist/types/types/method.d.ts.map +1 -1
  157. package/dist/types/types/scim.d.ts +269 -0
  158. package/dist/types/types/scim.d.ts.map +1 -0
  159. package/dist/types/types/tenant.d.ts +10 -0
  160. package/dist/types/types/tenant.d.ts.map +1 -1
  161. package/package.json +9 -1
  162. package/src/adapters/d1/migrations.ts +5 -0
  163. package/src/adapters/d1/session-store.ts +68 -0
  164. package/src/adapters/durable-object/session-store.ts +61 -0
  165. package/src/adapters/dynamo/session-store.ts +63 -0
  166. package/src/adapters/memory/session-store.ts +39 -0
  167. package/src/adapters/postgres/migrations.ts +6 -0
  168. package/src/adapters/postgres/session-store.ts +69 -0
  169. package/src/domain/callback.ts +215 -7
  170. package/src/domain/method-dispatch.ts +53 -1
  171. package/src/domain/method-route.ts +190 -1
  172. package/src/domain/scim/discovery.ts +262 -0
  173. package/src/domain/scim/filter.ts +295 -0
  174. package/src/domain/scim/handle.ts +597 -0
  175. package/src/domain/scim/patch.ts +807 -0
  176. package/src/domain/scim/resource.ts +358 -0
  177. package/src/http/context.ts +9 -0
  178. package/src/http/cookies.ts +19 -1
  179. package/src/http/handlers/authorize.ts +2 -22
  180. package/src/http/handlers/callback.ts +9 -1
  181. package/src/http/handlers/method-route.ts +72 -2
  182. package/src/http/handlers/scim.ts +107 -0
  183. package/src/http/middleware/tenant.ts +47 -1
  184. package/src/http/router.ts +9 -0
  185. package/src/index.ts +23 -0
  186. package/src/methods/saml-sp/acs.ts +449 -0
  187. package/src/methods/saml-sp/attributes.ts +146 -0
  188. package/src/methods/saml-sp/authnrequest.ts +129 -0
  189. package/src/methods/saml-sp/cache-provider.ts +76 -0
  190. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  191. package/src/methods/saml-sp/factory.ts +139 -0
  192. package/src/methods/saml-sp/index.ts +34 -0
  193. package/src/methods/saml-sp/metadata.ts +215 -0
  194. package/src/methods/saml-sp/method.ts +65 -0
  195. package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
  196. package/src/methods/saml-sp/saml-instance.ts +210 -0
  197. package/src/methods/saml-sp/slo-initiate.ts +180 -0
  198. package/src/methods/saml-sp/sls.ts +307 -0
  199. package/src/methods/saml-sp/types.ts +298 -0
  200. package/src/ports/CONSISTENCY.md +52 -24
  201. package/src/ports/audit-log.ts +18 -5
  202. package/src/ports/scim-directory.ts +166 -0
  203. package/src/ports/session-store.ts +24 -0
  204. package/src/types/error.ts +11 -0
  205. package/src/types/idp.ts +86 -0
  206. package/src/types/method.ts +110 -0
  207. package/src/types/scim.ts +280 -0
  208. package/src/types/tenant.ts +10 -0
@@ -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 }