@_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,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
+ }
@@ -11,12 +11,15 @@ 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"
17
18
  import type { AuthError } from "../types/error"
18
19
  import type {
19
20
  ExchangeAudience,
21
+ LogoutEventInput,
22
+ LogoutHookResult,
20
23
  PersistUpstreamTokens,
21
24
  RegisterClient,
22
25
  RenderPicker,
@@ -41,6 +44,8 @@ export type HttpDeps = {
41
44
  keyStore: KeyStore
42
45
  methodStore?: MethodStore
43
46
  auditLog?: AuditLog
47
+ /** See `IdPOptions.scimDirectory`. Absent ⇒ `/scim/v2/*` answers 501. */
48
+ scimDirectory?: ScimDirectory
44
49
  methodCache: MethodCache
45
50
  stateKeys: StateKeyRing
46
51
  /** Resolves the issuer URL — string or per-request function. */
@@ -49,6 +54,10 @@ export type HttpDeps = {
49
54
  callbackHostFor?: (tenantId: TenantId) => string
50
55
  resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
51
56
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
57
+ /** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
58
+ onLogout?: (
59
+ input: LogoutEventInput,
60
+ ) => Promise<LogoutHookResult> | LogoutHookResult
52
61
  persistUpstreamTokens?: PersistUpstreamTokens
53
62
  exchangeAudience?: ExchangeAudience
54
63
  renderPicker?: RenderPicker
@@ -7,7 +7,7 @@
7
7
  * strip method-returned `Set-Cookie` / security / `Cache-Control` headers so
8
8
  * methods cannot bypass policy.
9
9
  */
10
- import type { SetCookie } from "../types/method"
10
+ import type { CachePolicy, SetCookie } from "../types/method"
11
11
 
12
12
  /** Parse an incoming `Cookie:` header into a read-only map. */
13
13
  export function parseCookieHeader(header: string | null): Map<string, string> {
@@ -117,6 +117,24 @@ const STRIPPED_HEADERS = [
117
117
  "cache-control",
118
118
  ] as const
119
119
 
120
+ /**
121
+ * Serialize a `MethodResult.challenge`'s `CachePolicy` into a
122
+ * `Cache-Control` header value. The framework owns this header (methods
123
+ * cannot set it directly — see `STRIPPED_HEADERS`); a method opts into
124
+ * caching via the typed `cache` field and this renders it. Absent /
125
+ * `maxAge: 0` ⇒ `no-store` (the safe default for auth UI).
126
+ */
127
+ export function cacheControlHeader(cache: CachePolicy | undefined): string {
128
+ if (!cache) return "no-store"
129
+ if ((cache.maxAge ?? -1) === 0) return "no-store"
130
+ const parts: string[] = []
131
+ if (cache.isPrivate) parts.push("private")
132
+ if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
133
+ if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
134
+ if (cache.immutable) parts.push("immutable")
135
+ return parts.length ? parts.join(", ") : "no-store"
136
+ }
137
+
120
138
  export type ApplyOptions = {
121
139
  setCookies?: SetCookie[]
122
140
  cookieDefaults?: CookieDefaults
@@ -24,7 +24,7 @@ import type {
24
24
  import type { PickerContext, PickerMethod } from "../../types/picker"
25
25
  import { renderPicker as renderDefaultPicker } from "../../ui/picker"
26
26
 
27
- import { applyResponsePolicy } from "../cookies"
27
+ import { applyResponsePolicy, cacheControlHeader } from "../cookies"
28
28
  import type { HttpContext, HttpDeps } from "../context"
29
29
  import {
30
30
  authorizeDirectErrorResponse,
@@ -209,7 +209,7 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
209
209
  return applyResponsePolicy(out.response, {
210
210
  setCookies: [flowCookie, ...out.setCookies],
211
211
  cookieDefaults: deps.cookieDefaults,
212
- cacheControl: cacheControlFor(out.cache),
212
+ cacheControl: cacheControlHeader(out.cache),
213
213
  })
214
214
  }
215
215
  case "issue-code": {
@@ -340,23 +340,3 @@ function clearFlowCookie() {
340
340
  maxAge: 0,
341
341
  }
342
342
  }
343
-
344
- function cacheControlFor(
345
- cache:
346
- | {
347
- maxAge?: number
348
- sMaxAge?: number
349
- isPrivate?: boolean
350
- immutable?: boolean
351
- }
352
- | undefined,
353
- ): string {
354
- if (!cache) return "no-store"
355
- if ((cache.maxAge ?? -1) === 0) return "no-store"
356
- const parts: string[] = []
357
- if (cache.isPrivate) parts.push("private")
358
- if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
359
- if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
360
- if (cache.immutable) parts.push("immutable")
361
- return parts.length ? parts.join(", ") : "no-store"
362
- }
@@ -16,8 +16,16 @@ import { authorizeDirectErrorResponse } from "../errors"
16
16
 
17
17
  export function makeCallbackHandler(deps: HttpDeps) {
18
18
  return async (c: HttpContext): Promise<Response> => {
19
+ const tenant = c.get("tenant")
19
20
  const result = await handleCallback(
20
- { rawRequest: c.req.raw, cookies: c.get("cookies") },
21
+ {
22
+ rawRequest: c.req.raw,
23
+ cookies: c.get("cookies"),
24
+ // Only the IdP-initiated (no-state) path reads these; the
25
+ // SP-initiated path derives tenant from the consumed flow.
26
+ ...(tenant ? { tenant } : {}),
27
+ issuerUrl: c.get("issuerUrl"),
28
+ },
21
29
  {
22
30
  configStore: deps.configStore,
23
31
  sessionStore: deps.sessionStore,
@@ -7,11 +7,15 @@
7
7
  * `domain/method-route.handleMethodRoute` orchestrator handles flow lookup
8
8
  * + dispatch + result translation.
9
9
  */
10
- import { handleMethodRoute } from "../../domain/method-route"
10
+ import {
11
+ handleMethodRoute,
12
+ handlePublicMethodRoute,
13
+ } from "../../domain/method-route"
14
+ import type { RouteKey } from "../../domain/method-dispatch"
11
15
  import { authError } from "../../types/error"
12
16
  import { isErr } from "../../types/result"
13
17
 
14
- import { applyResponsePolicy } from "../cookies"
18
+ import { applyResponsePolicy, cacheControlHeader } from "../cookies"
15
19
  import type { HttpContext, HttpDeps } from "../context"
16
20
  import { authorizeDirectErrorResponse } from "../errors"
17
21
 
@@ -23,6 +27,72 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
23
27
  authError.invalidRequest("tenant unresolved"),
24
28
  )
25
29
  }
30
+
31
+ // Public fast-path (additive — leaves the cookie-gated path below
32
+ // byte-identical for every non-public request). Only a clean hit —
33
+ // well-formed `/m/<id>/<sub>`, method resolves, and the resolved
34
+ // method explicitly lists this route key in `publicRoutes` — is
35
+ // diverted here, cookie-free. Anything else falls through to the
36
+ // original flow-cookie-gated logic with its original error
37
+ // ordering. `methodCache.resolve` is a cache lookup; the duplicate
38
+ // resolve on the gated path is a cache hit.
39
+ {
40
+ const u = new URL(c.req.url)
41
+ const segs = u.pathname.split("/").filter(Boolean)
42
+ if (segs.length >= 3 && segs[0] === "m") {
43
+ const methodId = segs[1]!
44
+ const subPath = "/" + segs.slice(2).join("/")
45
+ const httpMethod =
46
+ c.req.method.toUpperCase() === "POST" ? "POST" : "GET"
47
+ const routeKey = `${httpMethod} ${subPath}` as RouteKey
48
+ const resolved = await deps.methodCache.resolve(tenant.config, methodId)
49
+ if (
50
+ !isErr(resolved) &&
51
+ resolved.value.publicRoutes?.includes(routeKey)
52
+ ) {
53
+ const issuerUrl = c.get("issuerUrl")
54
+ const callbackHost =
55
+ deps.callbackHostFor?.(tenant.id) ?? new URL(issuerUrl).host
56
+ // Mirrors the ACS URL derivation in domain/authorize.ts
57
+ // (callbackHost/path/url). Source of truth is there; the
58
+ // metadata.test.ts anti-drift test asserts the emitted
59
+ // entityID/ACS equal what buildAuthnRequestRedirect derives.
60
+ const callbackUrl = `${new URL(issuerUrl).protocol}//${callbackHost}/cb/${methodId}`
61
+ const pub = await handlePublicMethodRoute(
62
+ {
63
+ rawRequest: c.req.raw,
64
+ tenant,
65
+ method: resolved.value,
66
+ route: routeKey,
67
+ subPath,
68
+ dispatch: { state: "", callbackUrl, issuerUrl },
69
+ },
70
+ {
71
+ sessionStore: deps.sessionStore,
72
+ tokenStore: deps.tokenStore,
73
+ clock: deps.clock,
74
+ ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
75
+ ...(deps.onLogout ? { onLogout: deps.onLogout } : {}),
76
+ },
77
+ )
78
+ if (isErr(pub)) return authorizeDirectErrorResponse(pub.error)
79
+ if (pub.value.kind === "denied") {
80
+ return applyResponsePolicy(
81
+ new Response(`access_denied: ${pub.value.reason}`, {
82
+ status: 403,
83
+ headers: { "content-type": "text/plain" },
84
+ }),
85
+ { cookieDefaults: deps.cookieDefaults },
86
+ )
87
+ }
88
+ return applyResponsePolicy(pub.value.response, {
89
+ cacheControl: cacheControlHeader(pub.value.cache),
90
+ cookieDefaults: deps.cookieDefaults,
91
+ })
92
+ }
93
+ }
94
+ }
95
+
26
96
  const cookies = c.get("cookies")
27
97
  const flowId = cookies.get("idp.flow")
28
98
  if (!flowId) {
@@ -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
+ }