@_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,298 @@
1
+ /**
2
+ * SAML SP — public types.
3
+ *
4
+ * These types are the locked contract for the SAML SP method family and
5
+ * are intentionally introduced ahead of the runtime implementation so
6
+ * downstream callers can import them today, and so the public-API
7
+ * leak guard
8
+ * (`test/types/saml-sp-no-thirdparty-leaks.test.ts`) can verify
9
+ * the shapes before any `@node-saml/*` or `xml-crypto` code lands.
10
+ *
11
+ * No third-party types appear here. `@node-saml/node-saml`'s `Profile`
12
+ * is mapped onto `SamlSpProperties` at the wrapper boundary; consumers
13
+ * never see node-saml's surface.
14
+ *
15
+ * See `docs/plans/claude/saml-sp-plan.md` for the architectural
16
+ * decisions (SAML-AD1–AD7) backing this shape.
17
+ */
18
+
19
+ /**
20
+ * Standard SAML 2.0 NameID formats we support.
21
+ *
22
+ * - `persistent` — opaque, stable per user/SP pair; preferred.
23
+ * - `transient` — opaque, single-session.
24
+ * - `emailAddress` — RFC 822 email; common with legacy IdPs.
25
+ * - `unspecified` — IdP picks; pass through verbatim.
26
+ */
27
+ export type SamlNameIdFormat =
28
+ | "persistent"
29
+ | "transient"
30
+ | "emailAddress"
31
+ | "unspecified"
32
+
33
+ /**
34
+ * One field's source. Either the assertion's NameID or a named
35
+ * attribute. `format` narrows attribute lookup when an IdP issues
36
+ * multiple attributes with the same `Name` but different `NameFormat`.
37
+ */
38
+ export type SamlAttributeRef =
39
+ | { source: "nameId" }
40
+ | { source: "attribute"; name: string; format?: string }
41
+
42
+ /**
43
+ * How the SAML method translates a verified assertion into the
44
+ * `providerSubject` + per-claim fields that flow into the host's
45
+ * `success` callback. Mirrors the OIDC method's claim-mapping
46
+ * configuration — the host owns the final `SubjectClaim`; this map
47
+ * just normalises the SAML side.
48
+ */
49
+ export type SamlAttributeMapping = {
50
+ /** Which attribute becomes `providerSubject`. Defaults to NameID. */
51
+ subject?: SamlAttributeRef
52
+ email?: SamlAttributeRef
53
+ /** SAML assertions are typically issued only after IdP-side verification. */
54
+ emailVerified?: { source: "literal"; value: boolean }
55
+ name?: SamlAttributeRef
56
+ /** Multi-valued — the mapper preserves array shape. */
57
+ groups?: SamlAttributeRef
58
+ /** Pass-through map for additional claims hosts want surfaced. */
59
+ custom?: Record<string, SamlAttributeRef>
60
+ }
61
+
62
+ /**
63
+ * A single PEM-encoded IdP signing certificate, optionally bounded by a
64
+ * validity window. The runtime verifier accepts any cert whose window
65
+ * covers `now` — supports overlapping hot rotation without re-deploys.
66
+ */
67
+ export type SamlIdpSigningCert = {
68
+ pem: string
69
+ /** Inclusive lower bound. Unix ms. Omit for no lower bound. */
70
+ notBefore?: number
71
+ /** Exclusive upper bound. Unix ms. Omit for no upper bound. */
72
+ notAfter?: number
73
+ }
74
+
75
+ /**
76
+ * Identity-Provider-side configuration the host paste/imports from the
77
+ * IdP's metadata XML (or uploads a metadata URL we fetch once). Stable
78
+ * across deploys — keyed by `(tenantId, methodId)` in `MethodStore`.
79
+ */
80
+ export type SamlIdpConfig = {
81
+ entityId: string
82
+ /** SSO endpoint URL. HTTP-Redirect binding is the default. */
83
+ ssoUrl: string
84
+ /** Single Logout endpoint URL. Optional; Phase 3 deliverable. */
85
+ sloUrl?: string
86
+ /** Preferred NameID format requested in `AuthnRequest`. */
87
+ nameIdFormat?: SamlNameIdFormat
88
+ /** One or more IdP signing certs. ≥1 required. Hot-rotatable. */
89
+ signingCerts: ReadonlyArray<SamlIdpSigningCert>
90
+ }
91
+
92
+ /**
93
+ * IdP-initiated SSO binding. When set, the ACS endpoint accepts
94
+ * unsolicited SAML Responses (no `InResponseTo`) and synthesizes a
95
+ * flow record using these defaults so the existing
96
+ * `MethodResult.success` path can run end-to-end.
97
+ *
98
+ * Documented in plan SAML-AD7 — this is the one architectural carve-out
99
+ * SAML imposes on the framework. Omit to reject unsolicited Responses
100
+ * with `invalid_request` (the conservative default).
101
+ */
102
+ export type SamlIdpInitiatedConfig = {
103
+ defaultClientId: string
104
+ defaultRedirectUri: string
105
+ defaultScopes?: string[]
106
+ }
107
+
108
+ /**
109
+ * Tenant-supplied configuration for a SAML SP method instance.
110
+ * Validated by `samlSpFactory.configSchema` (Standard Schema v1).
111
+ */
112
+ export type SamlSpConfig = {
113
+ idp: SamlIdpConfig
114
+ attributeMapping: SamlAttributeMapping
115
+ /** Whether to sign outbound `AuthnRequest`. Default `false`. */
116
+ signAuthnRequest?: boolean
117
+ /**
118
+ * Per-connection SP signing keypair (SAML-AD: O3). **Decoupled from
119
+ * the OIDC `KeyStore` on purpose** — the SP signing cert is pinned at
120
+ * the IdP and rotated as an IdP-coordination event, not on the OIDC
121
+ * token-key schedule; this also keeps the design KMS-agnostic.
122
+ * `privateKeyPem` signs the `AuthnRequest`; `certPem` is what the IdP
123
+ * pins and what SP metadata advertises. Required iff
124
+ * `signAuthnRequest` is `true`. Treat `privateKeyPem` as a secret:
125
+ * the host should encrypt the `MethodStore` at rest (or supply it via
126
+ * its own resolver) — same handling as any per-tenant credential.
127
+ */
128
+ signingKey?: { privateKeyPem: string; certPem: string }
129
+ /**
130
+ * Accept `<saml:EncryptedAssertion>` responses. **Off by default** —
131
+ * a connection only opts in when its IdP is configured to encrypt.
132
+ * When `false`, an encrypted assertion is rejected (the SP advertises
133
+ * no decryption cert and node-saml has no `decryptionPvk`). Requires
134
+ * `decryptionKey` when `true` (enforced by `configSchema`).
135
+ */
136
+ allowEncryptedAssertions?: boolean
137
+ /**
138
+ * Per-connection SP **decryption** keypair — the private half the IdP
139
+ * encrypts assertions to. Same SAML-AD O3 rationale as `signingKey`:
140
+ * decoupled from the OIDC `KeyStore` (the IdP pins the matching cert
141
+ * via SP metadata; rotation is an IdP-coordination event;
142
+ * KMS-agnostic). Required iff `allowEncryptedAssertions` is `true`.
143
+ * Treat `privateKeyPem` as a secret: the host should encrypt the
144
+ * `MethodStore` at rest (or supply it via its own resolver) — same
145
+ * handling as `signingKey.privateKeyPem` and any per-tenant
146
+ * credential. `certPem` is the matching X.509 cert the IdP encrypts
147
+ * to; SP metadata advertises it as a `use="encryption"`
148
+ * `KeyDescriptor` (same advertise-only-what-we-serve invariant as
149
+ * `signingKey`).
150
+ */
151
+ decryptionKey?: { privateKeyPem: string; certPem: string }
152
+ idpInitiated?: SamlIdpInitiatedConfig
153
+ /** Clock skew allowance for `NotBefore` / `NotOnOrAfter`. Seconds. */
154
+ clockSkewSeconds?: number
155
+ /**
156
+ * Override the derived SP entityID.
157
+ *
158
+ * By default the entityID is derived as
159
+ * `<issuerUrl>/<tenantId>/<methodId>` (SAML-AD5) — stable, no config
160
+ * required, and guaranteed to match what SP metadata publishes. Set
161
+ * this **only** to adopt an entityID that already exists at the IdP,
162
+ * so an existing SAML app can be migrated without the customer
163
+ * editing their production SSO config.
164
+ *
165
+ * The override flows through every consumer at once — `AuthnRequest`
166
+ * issuer, `AudienceRestriction` validation, SP metadata, and logout
167
+ * messages — so the anti-drift invariant holds either way. Changing
168
+ * it on a live connection invalidates the IdP-side trust config;
169
+ * treat it as an IdP-coordination event.
170
+ */
171
+ spEntityId?: string
172
+ /**
173
+ * Set `ForceAuthn="true"` on the outbound `AuthnRequest`, asking the
174
+ * IdP to re-authenticate the user even if it has a live session.
175
+ * Default `false`.
176
+ *
177
+ * Note that this is a *request*: SAML gives the IdP no obligation to
178
+ * honour it, and there is no way to verify from the Response that it
179
+ * did. Do not treat a successful assertion as proof of fresh
180
+ * authentication.
181
+ */
182
+ forceAuthn?: boolean
183
+ /**
184
+ * Request specific authentication context classes (e.g. MFA) from
185
+ * the IdP via `<RequestedAuthnContext>`.
186
+ *
187
+ * **Omitted ⇒ no `RequestedAuthnContext` element is sent at all**,
188
+ * which lets the IdP apply its own sign-on policy. That is the right
189
+ * default for nearly every deployment: a `RequestedAuthnContext` the
190
+ * IdP cannot satisfy exactly is answered with `NoAuthnContext`
191
+ * instead of a login, and an MFA policy at the IdP is a common way
192
+ * to *not* satisfy `PasswordProtectedTransport` under
193
+ * `Comparison="exact"`.
194
+ *
195
+ * Set it only when the IdP has told you which class refs it honours.
196
+ * `comparison` maps to the `Comparison` attribute and defaults to
197
+ * `"exact"`; `"minimum"` is usually the safer choice when requesting
198
+ * MFA.
199
+ *
200
+ * Requesting a context does **not** verify one was used — read
201
+ * `SamlSpProperties.authnContextClassRef` for what the IdP actually
202
+ * asserted.
203
+ */
204
+ requestedAuthnContext?: {
205
+ /** Full URNs, e.g. `urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthn`. */
206
+ classRefs: ReadonlyArray<string>
207
+ comparison?: "exact" | "minimum" | "maximum" | "better"
208
+ }
209
+ /**
210
+ * Require the `<saml:Assertion>` itself to carry a valid XML-DSig.
211
+ * **Defaults to `true` and should stay that way** — the identity,
212
+ * conditions, and audience all live inside the assertion, so signing
213
+ * it is what actually binds them.
214
+ *
215
+ * Set `false` only for an IdP that signs the outer `<Response>` and
216
+ * nothing else, and only together with `requireSignedResponse: true`.
217
+ * The schema refuses to let both be off.
218
+ */
219
+ requireSignedAssertion?: boolean
220
+ /**
221
+ * Require the outer `<samlp:Response>` to carry a valid XML-DSig.
222
+ * Default `false` — requiring it is stricter than the Okta / Entra
223
+ * default and would reject the majority of real IdPs. Enable it for
224
+ * an IdP that signs the Response, either as defence in depth
225
+ * alongside a signed assertion or (with
226
+ * `requireSignedAssertion: false`) as the only signature on offer.
227
+ */
228
+ requireSignedResponse?: boolean
229
+ }
230
+
231
+ /**
232
+ * Method-private state stashed in `FlowRecord.methodState` for the
233
+ * duration of an SP-initiated flow.
234
+ *
235
+ * `InResponseTo` correlation is handled out-of-band by node-saml's
236
+ * `CacheProvider` (backed by `methodScratch`), so the outstanding
237
+ * request id does **not** live here — only the framework state
238
+ * envelope echoed as RelayState and the issuance timestamp.
239
+ */
240
+ export type SamlSpState = {
241
+ relayState: string
242
+ issuedAt: number
243
+ /**
244
+ * SP entityID + ACS URL computed at AuthnRequest time, where the
245
+ * framework's dispatch context is available. The ACS dispatch has
246
+ * no dispatch context, so these are read back from here to validate
247
+ * the assertion's `AudienceRestriction` / `Recipient` against the
248
+ * exact values the IdP saw in the request.
249
+ */
250
+ spEntityId: string
251
+ acsUrl: string
252
+ }
253
+
254
+ /**
255
+ * Properties handed to the host's `IdPOptions.success` callback on a
256
+ * successful SAML authentication. The host translates these into the
257
+ * final `SubjectClaim` it owns.
258
+ *
259
+ * `raw.responseXml` is provided as an escape hatch for hosts that want
260
+ * to inspect the verified Response themselves; the wrapper has already
261
+ * run the full signature gauntlet by the time these properties leave
262
+ * the method.
263
+ */
264
+ export type SamlSpProperties = {
265
+ nameId: {
266
+ value: string
267
+ format: SamlNameIdFormat
268
+ }
269
+ attributes: Record<string, string | string[]>
270
+ /** Used for Single Logout correlation (Phase 3). */
271
+ sessionIndex?: string
272
+ /** Unix ms — the assertion's `AuthnInstant`. */
273
+ authnInstant: number
274
+ /**
275
+ * Unix ms — the `AuthnStatement/@SessionNotOnOrAfter` the IdP
276
+ * asserted, when it supplied one. This is the IdP's own view of when
277
+ * its session for this user expires.
278
+ *
279
+ * The library does **not** act on it: token and session lifetimes are
280
+ * host policy, and this library owns no session. Hosts that want
281
+ * "when their IdP session ends, ours ends" should clamp their own
282
+ * session/token TTL to this value in the `success` callback.
283
+ */
284
+ sessionNotOnOrAfter?: number
285
+ /**
286
+ * The `AuthnContext/AuthnContextClassRef` the IdP actually asserted —
287
+ * i.e. how it says it authenticated the user. Absent when the
288
+ * assertion carries none.
289
+ *
290
+ * This is the value to check for step-up decisions ("was this really
291
+ * MFA?"). `SamlSpConfig.requestedAuthnContext` only *asks*; this is
292
+ * the answer, and the two can differ.
293
+ */
294
+ authnContextClassRef?: string
295
+ raw: {
296
+ responseXml: string
297
+ }
298
+ }
@@ -15,30 +15,34 @@ overspecifies adapters that don't need that on the read-eventual paths
15
15
 
16
16
  ## Per-method consistency contracts
17
17
 
18
- | Port | Method | Required | Why |
19
- | -------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
20
- | `TokenStore` | `saveCode(code, payload, ttl)` | **Strong, atomic.** Payload **encrypted at rest** with a key from `KeyStore`; `ttl ≤ 60 s` (framework refuses larger). | Code must be created exactly once and visible to the next `consumeCode` on any node. Payload may carry upstream tokens — see _Code payload confidentiality_ in plan. |
21
- | `TokenStore` | `consumeCode(code)` | **Strong, CAS.** Returns the **decrypted** payload on success. | Single-use; second consumption must fail deterministically. Race with itself must resolve to one winner. |
22
- | `TokenStore` | `saveRefresh(refresh, payload)` | **Strong, atomic.** | New refresh tokens issued during rotation must be immediately retrievable on the next request. |
23
- | `TokenStore` | `consumeRefresh(refresh, options)` | **Strong, CAS with reuse-detection window** (default 60 s). | Core of refresh-token rotation security. Concurrent presentations resolve to one winner; reuse within the window triggers the revoke chain. |
24
- | `TokenStore` | `peekRefresh(refresh)` | **Eventual acceptable.** Non-destructive read; must never mark the token consumed. | Used by `/revoke` for token-to-client binding (RFC 7009 §2.2) and by the refresh-grant for client auth + audit fields. Race losers fall through to `consumeRefresh`, which is the strong gate, so a stale peek can only delay — not bypass — single-use enforcement. |
25
- | `TokenStore` | `revokeFamily(family)` | Strong preferred; eventual acceptable with documented SLA. | Reuse-detection must invalidate the whole chain. Brief lag is tolerable. |
26
- | `TokenStore` | `revokeBySubject(tenant, subject)` | Strong preferred; eventual acceptable with documented SLA. | Revocation must propagate quickly. A few seconds of lag is tolerable. |
27
- | `SessionStore` | `saveFlow(flowId, payload, ttl)` | **Strong, atomic.** `ttl == expiresAt - createdAt`. | Flow record must be visible on the callback request to `consumeFlow`. |
28
- | `SessionStore` | `updateFlowMethodState(flowId, state)` | **Strong, atomic.** Resolves **before** the user-agent redirect is sent. | Upstream PKCE verifier / nonce must be durably persisted before the redirect, otherwise the callback cannot validate. |
29
- | `SessionStore` | `consumeFlow(flowId)` | **Strong, atomic delete-on-read** that **returns the full `FlowRecord`** (`CAS` or `DELETE … RETURNING`). | Single-use; concurrent consumption resolves to one winner. The record is returned so the framework can snapshot fields into the auth-code payload before disposal. |
30
- | `SessionStore` | `savePar(uri, payload, ttl)` (optional) | **Strong, atomic.** `ttl` default 60 s. | RFC 9126 §3 — the PAR record must be visible to the next `consumePar` on any node. One-shot read. |
31
- | `SessionStore` | `consumePar(uri)` (optional) | **Strong, atomic delete-on-read.** | RFC 9126 §4 — `request_uri` is single-use; concurrent presentations of the same uri resolve to one winner. Same semantics as `consumeFlow`. |
32
- | `SessionStore` | `createSession / readSession / revokeSession` (optional long-lived) | **Strong.** | Session creation must be immediately readable on the next request. |
33
- | `TokenStore` | `recordDpopJti(jti, ttlMs)` (optional) | **Strong, atomic record-or-fail.** | RFC 9449 §11.1 replay protection requires single-use enforcement on the jti within the TTL window. A re-presentation must return `invalid_grant` deterministically. Adapters without this method cannot satisfy DPoP and the verifier surfaces `invalid_dpop_proof`. |
34
- | `KeyStore` | `currentSigningKey()` / `currentEncryptionKey()` | Strong. | Active key must be unambiguous. |
35
- | `KeyStore` | `signingKeys()` (JWKS) | Eventual OK (with TTL). | Verifiers tolerate brief JWKS lag during rotation. |
36
- | `KeyStore` | `getEncryptionKey(kid)` | Strong. | Required to decrypt code payloads encrypted under non-current keys during the overlap window. |
37
- | `ConfigStore` | `getTenantConfig(id)` | **Eventual + bounded staleness (TTL 60 s).** Invalidation hook fires on update. | Read-heavy, write-rare. Aggressive caching is desirable. |
38
- | `ConfigStore` | `putTenantConfig(config)` | **Strong write + immediate invalidation** of cached entries for the affected `TenantId`. | Subsequent `getTenantConfig` must reflect the new value once the write resolves. |
39
- | `MethodStore` | `getMethodConfig` / `listMethods` | Same as `ConfigStore.getTenantConfig`. | Subset of tenant config. |
40
- | `MethodStore` | `putMethodConfig` / `deleteMethodConfig` | Strong write + immediate invalidation. | Console-driven mutations must take effect on the next request. |
41
- | `AuditLog` | `log(event)` | Append-only, durable. Cross-instance ordering **not** required (consumers sort by `timestamp` + `actor`). | Loss = audit gap; ordering is a UI / SIEM concern. |
18
+ | Port | Method | Required | Why |
19
+ | --------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
20
+ | `TokenStore` | `saveCode(code, payload, ttl)` | **Strong, atomic.** Payload **encrypted at rest** with a key from `KeyStore`; `ttl ≤ 60 s` (framework refuses larger). | Code must be created exactly once and visible to the next `consumeCode` on any node. Payload may carry upstream tokens — see _Code payload confidentiality_ in plan. |
21
+ | `TokenStore` | `consumeCode(code)` | **Strong, CAS.** Returns the **decrypted** payload on success. | Single-use; second consumption must fail deterministically. Race with itself must resolve to one winner. |
22
+ | `TokenStore` | `saveRefresh(refresh, payload)` | **Strong, atomic.** | New refresh tokens issued during rotation must be immediately retrievable on the next request. |
23
+ | `TokenStore` | `consumeRefresh(refresh, options)` | **Strong, CAS with reuse-detection window** (default 60 s). | Core of refresh-token rotation security. Concurrent presentations resolve to one winner; reuse within the window triggers the revoke chain. |
24
+ | `TokenStore` | `peekRefresh(refresh)` | **Eventual acceptable.** Non-destructive read; must never mark the token consumed. | Used by `/revoke` for token-to-client binding (RFC 7009 §2.2) and by the refresh-grant for client auth + audit fields. Race losers fall through to `consumeRefresh`, which is the strong gate, so a stale peek can only delay — not bypass — single-use enforcement. |
25
+ | `TokenStore` | `revokeFamily(family)` | Strong preferred; eventual acceptable with documented SLA. | Reuse-detection must invalidate the whole chain. Brief lag is tolerable. |
26
+ | `TokenStore` | `revokeBySubject(tenant, subject)` | Strong preferred; eventual acceptable with documented SLA. | Revocation must propagate quickly. A few seconds of lag is tolerable. |
27
+ | `SessionStore` | `saveFlow(flowId, payload, ttl)` | **Strong, atomic.** `ttl == expiresAt - createdAt`. | Flow record must be visible on the callback request to `consumeFlow`. |
28
+ | `SessionStore` | `updateFlowMethodState(flowId, state)` | **Strong, atomic.** Resolves **before** the user-agent redirect is sent. | Upstream PKCE verifier / nonce must be durably persisted before the redirect, otherwise the callback cannot validate. |
29
+ | `SessionStore` | `consumeFlow(flowId)` | **Strong, atomic delete-on-read** that **returns the full `FlowRecord`** (`CAS` or `DELETE … RETURNING`). | Single-use; concurrent consumption resolves to one winner. The record is returned so the framework can snapshot fields into the auth-code payload before disposal. |
30
+ | `SessionStore` | `savePar(uri, payload, ttl)` (optional) | **Strong, atomic.** `ttl` default 60 s. | RFC 9126 §3 — the PAR record must be visible to the next `consumePar` on any node. One-shot read. |
31
+ | `SessionStore` | `consumePar(uri)` (optional) | **Strong, atomic delete-on-read.** | RFC 9126 §4 — `request_uri` is single-use; concurrent presentations of the same uri resolve to one winner. Same semantics as `consumeFlow`. |
32
+ | `SessionStore` | `createSession / readSession / revokeSession` (optional long-lived) | **Strong.** | Session creation must be immediately readable on the next request. |
33
+ | `SessionStore` | `saveScratch / readScratch / deleteScratch` (optional) | **Strong, TTL-respecting.** Implement the trio together; partial implementations are not supported. | Backs `MethodContext.methodScratch` for methods that need cross-flow per-instance state (e.g. SAML SP assertion-ID replay protection). Keys arrive already namespaced by `(tenantId, methodId)` the adapter stores opaque key/value pairs. |
34
+ | `TokenStore` | `recordDpopJti(jti, ttlMs)` (optional) | **Strong, atomic record-or-fail.** | RFC 9449 §11.1 — replay protection requires single-use enforcement on the jti within the TTL window. A re-presentation must return `invalid_grant` deterministically. Adapters without this method cannot satisfy DPoP and the verifier surfaces `invalid_dpop_proof`. |
35
+ | `KeyStore` | `currentSigningKey()` / `currentEncryptionKey()` | Strong. | Active key must be unambiguous. |
36
+ | `KeyStore` | `signingKeys()` (JWKS) | Eventual OK (with TTL). | Verifiers tolerate brief JWKS lag during rotation. |
37
+ | `KeyStore` | `getEncryptionKey(kid)` | Strong. | Required to decrypt code payloads encrypted under non-current keys during the overlap window. |
38
+ | `ConfigStore` | `getTenantConfig(id)` | **Eventual + bounded staleness (TTL 60 s).** Invalidation hook fires on update. | Read-heavy, write-rare. Aggressive caching is desirable. |
39
+ | `ConfigStore` | `putTenantConfig(config)` | **Strong write + immediate invalidation** of cached entries for the affected `TenantId`. | Subsequent `getTenantConfig` must reflect the new value once the write resolves. |
40
+ | `MethodStore` | `getMethodConfig` / `listMethods` | Same as `ConfigStore.getTenantConfig`. | Subset of tenant config. |
41
+ | `MethodStore` | `putMethodConfig` / `deleteMethodConfig` | Strong write + immediate invalidation. | Console-driven mutations must take effect on the next request. |
42
+ | `AuditLog` | `log(event)` | Append-only, durable. Cross-instance ordering **not** required (consumers sort by `timestamp` + `actor`). | Loss = audit gap; ordering is a UI / SIEM concern. |
43
+ | `ScimDirectory` | `createUser` / `replaceUser` / `patchUser` / `deleteUser` | **Strong, read-your-writes.** | A SCIM client issues `GET /Users?filter=userName eq "…"` immediately after a create to confirm it; an eventually-consistent read there makes the client create the user a second time. Duplicate users are the classic SCIM failure and are painful to unpick. |
44
+ | `ScimDirectory` | `getUser` / `findUsers` | **Read-your-writes for the tenant's own recent writes**; otherwise eventual is acceptable. | Same reason. Cross-tenant staleness is harmless — SCIM reads are always tenant-scoped. |
45
+ | `ScimDirectory` | uniqueness of `userName` | **Host-enforced.** Return `authError.conflict(...)` on collision. | The library stores no user rows, so it cannot enforce uniqueness. A host that checks-then-writes without a constraint will race under Okta's initial import, which is heavily concurrent. |
42
46
 
43
47
  ## Implications for adapter choice
44
48
 
@@ -64,6 +68,12 @@ demonstrate, under simulated replication lag:
64
68
  2. `consumeRefresh` CAS resolves to exactly one winner under concurrent
65
69
  attempts on the same token.
66
70
  3. `revokeBySubject` propagates within the documented SLA.
71
+ 4. `readScratch` immediately after the matching `saveScratch` returns the
72
+ value. Scratch backs SAML SP InResponseTo replay protection: the
73
+ AuthnRequest is correlated at the ACS by a scratch read that must see
74
+ the write made when the request was issued. The D1 adapter pins all
75
+ three scratch operations to the primary via the Sessions API
76
+ (`primarySession`), the same path `consumeFlow` uses.
67
77
 
68
78
  Read-eventual paths (`ConfigStore.getTenantConfig`, JWKS) may use replicas
69
79
  freely.
@@ -93,6 +103,11 @@ The fixture set covers, at minimum:
93
103
  one-shot consume atomicity, expiry enforcement, ttl-0 rejection.
94
104
  - **DPoP jti (`recordDpopJti`)** — first record succeeds, replay within
95
105
  TTL fails with `invalid_grant`, post-TTL the slot is freed for reuse.
106
+ - **Method scratch (`supportsScratch: true`)** — `saveScratch` /
107
+ `readScratch` / `deleteScratch` round-trip, overwrite semantics on
108
+ same key, expiry enforcement, idempotent `deleteScratch`, ttl-0
109
+ rejection. Keys are opaque to the adapter (the framework scopes them
110
+ upstream); a single test confirms that distinct keys are isolated.
96
111
 
97
112
  ## Optional methods and graceful degradation
98
113
 
@@ -110,6 +125,19 @@ caught up yet:
110
125
  does not support DPoP replay protection"`. Memory adapter
111
126
  implements; production adapters add it the same way `consumeRefresh`
112
127
  uses an atomic write-once primitive.
128
+ - `SessionStore.saveScratch / readScratch / deleteScratch` — required
129
+ for methods that hold cross-flow per-instance state (SAML SP replay
130
+ protection is the first user). Without them,
131
+ `MethodContext.methodScratch.put/get/delete` returns an
132
+ `internal_error` whose description names the missing operation.
133
+ Memory **and all four production adapters** (Postgres, D1, DynamoDB,
134
+ Durable Object) implement the trio as a TTL-respecting key/value
135
+ store keyed by an opaque string; each is opted into the
136
+ `supportsScratch` conformance cases. Semantics are upsert /
137
+ TTL-filtered read / idempotent delete — there is no atomic
138
+ delete-on-read (unlike `consumeFlow`). On DynamoDB the row also
139
+ carries a native `ttl` attribute for backstop eviction, but reads
140
+ still filter on the adapter clock because native TTL is best-effort.
113
141
 
114
142
  The framework never advertises a feature in discovery that the wired
115
143
  adapters cannot actually serve: `pushed_authorization_request_endpoint`
@@ -93,16 +93,29 @@ export type AuditEvent =
93
93
  }
94
94
  | {
95
95
  /**
96
- * Emitted by `/end_session` (OIDC RP-Initiated Logout 1.0 §2) after
97
- * processing the logout request — regardless of whether a
98
- * `post_logout_redirect_uri` was supplied. `subjectId` is present
99
- * when the request carried an `id_token_hint` that successfully
100
- * verified; absent otherwise.
96
+ * Emitted after a logout is processed. Two channels, discriminated
97
+ * by `via`:
98
+ *
99
+ * - `rp_initiated` (or absent the default): `/end_session`
100
+ * (OIDC RP-Initiated Logout 1.0 §2), regardless of whether a
101
+ * `post_logout_redirect_uri` was supplied. `subjectId` present
102
+ * when an `id_token_hint` verified; absent otherwise.
103
+ * - `upstream_slo`: an upstream IdP notified us a federated
104
+ * session ended (SAML front-channel Single Logout). `methodId`
105
+ * / `methodKind` identify the federation connection;
106
+ * `subjectId` present only when the host's `onLogout` returned
107
+ * a subject to revoke.
108
+ *
109
+ * `via` is general (OIDC back-channel logout would reuse
110
+ * `upstream_slo`), not SAML-specific surface.
101
111
  */
102
112
  kind: "session_logout"
103
113
  tenantId: TenantId
104
114
  clientId?: string
105
115
  subjectId?: string
116
+ via?: "rp_initiated" | "upstream_slo"
117
+ methodId?: string
118
+ methodKind?: string
106
119
  }
107
120
  | {
108
121
  /**
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `ScimDirectory` — the host's user directory, as SCIM needs to see it.
3
+ *
4
+ * This is the boundary from `SCIM-AD2`: the library owns the SCIM
5
+ * protocol (routing, bearer auth, schema validation, PATCH
6
+ * normalization, error envelope, pagination, discovery docs) and the
7
+ * host owns every byte of persistence. The library stores no user data.
8
+ *
9
+ * That split is not a compromise — it is the same protocol-over-
10
+ * host-owned-state shape as `ConfigStore`, `MethodStore` and the
11
+ * `success` callback. The host's Users table is the host's.
12
+ *
13
+ * **Consistency:** read-your-writes is required. A SCIM client
14
+ * (notably Okta) will `GET /Users?filter=userName eq "…"` immediately
15
+ * after a `POST /Users` to confirm the create; an eventually-consistent
16
+ * read there produces duplicate users. See `ports/CONSISTENCY.md`.
17
+ *
18
+ * **Errors.** SCIM clients retry `5xx` and give up on `4xx`, so which
19
+ * error you return decides whether a failure is surfaced to an admin or
20
+ * retried forever:
21
+ *
22
+ * - `authError.conflict(...)` → `409 uniqueness`. A collision only you
23
+ * can detect, because only you store the rows.
24
+ * - `authError.invalidRequest(...)` → `400 invalidValue`. A
25
+ * **permanent** rejection — you understood the request and will
26
+ * never accept it. Use it when `addMembers` names a user you do not
27
+ * have (an IdP's group push can reference a member its user push
28
+ * filtered out, or one deleted between operations), or when the data
29
+ * violates a constraint you cannot satisfy.
30
+ * - anything else → `500`, which the client retries. Correct for a
31
+ * transient fault, and far better than reporting a success you did
32
+ * not perform — but returning it for a permanent problem means the
33
+ * IdP retries the same doomed request indefinitely.
34
+ *
35
+ * Your message is passed through on both `4xx` paths; it is what an IdP
36
+ * admin reads in the provisioning log, so make it specific.
37
+ */
38
+ import type { Result } from "../types/result"
39
+ import type {
40
+ ScimGroupPatch,
41
+ ScimGroupQuery,
42
+ ScimGroupRecord,
43
+ ScimGroupWrite,
44
+ ScimPage,
45
+ ScimUserPatch,
46
+ ScimUserQuery,
47
+ ScimUserRecord,
48
+ ScimUserWrite,
49
+ } from "../types/scim"
50
+ import type { TenantId } from "../types/tenant"
51
+
52
+ export type ScimDirectory = {
53
+ /** `null` (not an error) when no user has that id in this tenant. */
54
+ getUser(
55
+ tenantId: TenantId,
56
+ id: string,
57
+ ): Promise<Result<ScimUserRecord | null>>
58
+
59
+ /**
60
+ * Query users. `query.startIndex` is 1-based and `query.count` is
61
+ * already clamped; `query.totalResults` in the response must be the
62
+ * full match count, not the page length.
63
+ */
64
+ findUsers(
65
+ tenantId: TenantId,
66
+ query: ScimUserQuery,
67
+ ): Promise<Result<ScimPage<ScimUserRecord>>>
68
+
69
+ /**
70
+ * Create a user. Return `conflict` if `userName` (or another
71
+ * uniqueness constraint of yours) is already taken — the library
72
+ * cannot check that for you.
73
+ */
74
+ createUser(
75
+ tenantId: TenantId,
76
+ user: ScimUserWrite,
77
+ ): Promise<Result<ScimUserRecord>>
78
+
79
+ /**
80
+ * Full replace (`PUT`). Attributes absent from `user` are cleared, per
81
+ * RFC 7644 §3.5.1 — this is a replace, not a merge. Use `patchUser`
82
+ * for partial updates.
83
+ */
84
+ replaceUser(
85
+ tenantId: TenantId,
86
+ id: string,
87
+ user: ScimUserWrite,
88
+ ): Promise<Result<ScimUserRecord>>
89
+
90
+ /**
91
+ * Apply a normalized delta. Present key ⇒ set; absent ⇒ leave alone;
92
+ * `null` ⇒ clear. The library has already resolved SCIM path
93
+ * expressions, so no parsing is needed here.
94
+ *
95
+ * `{ active: false }` is the ordinary deprovisioning signal and is
96
+ * usually the single most important operation to implement correctly.
97
+ */
98
+ patchUser(
99
+ tenantId: TenantId,
100
+ id: string,
101
+ patch: ScimUserPatch,
102
+ ): Promise<Result<ScimUserRecord>>
103
+
104
+ /**
105
+ * Hard delete (`DELETE`). Distinct from deactivation on purpose
106
+ * (`SCIM-AD8`): the library will not quietly turn a destructive
107
+ * request into a soft one. Hosts that do not want cascading deletes
108
+ * should implement this as a tombstone and say so in their runbook —
109
+ * but that decision is theirs to make explicitly, not the library's to
110
+ * make silently.
111
+ */
112
+ deleteUser(tenantId: TenantId, id: string): Promise<Result<void>>
113
+
114
+ // ─── Groups ───
115
+ //
116
+ // Optional as a set: a host that only needs user provisioning can omit
117
+ // all six, and `/scim/v2/Groups` then answers 501 instead of failing
118
+ // at runtime. Implement all of them or none — a half-implemented
119
+ // Groups surface fails an IdP's group push in confusing ways.
120
+
121
+ getGroup?(
122
+ tenantId: TenantId,
123
+ id: string,
124
+ ): Promise<Result<ScimGroupRecord | null>>
125
+
126
+ /**
127
+ * `query.excludeMembers` is set when the client asked for
128
+ * `excludedAttributes=members`. Honour it by not loading membership —
129
+ * Okta sets it while enumerating groups, and ignoring it turns a cheap
130
+ * listing into a fan-out read per group.
131
+ */
132
+ findGroups?(
133
+ tenantId: TenantId,
134
+ query: ScimGroupQuery,
135
+ ): Promise<Result<ScimPage<ScimGroupRecord>>>
136
+
137
+ createGroup?(
138
+ tenantId: TenantId,
139
+ group: ScimGroupWrite,
140
+ ): Promise<Result<ScimGroupRecord>>
141
+
142
+ /** Full replace, including membership. */
143
+ replaceGroup?(
144
+ tenantId: TenantId,
145
+ id: string,
146
+ group: ScimGroupWrite,
147
+ ): Promise<Result<ScimGroupRecord>>
148
+
149
+ /**
150
+ * Apply a normalized group delta.
151
+ *
152
+ * Exactly one membership shape arrives at a time: `members` (replace
153
+ * everything) or `addMembers` / `removeMembers` (incremental). The
154
+ * incremental form exists so a 20,000-member group does not have to be
155
+ * read and rewritten to add one person — see `SCIM-AD9`. Adding an
156
+ * existing member or removing an absent one must succeed quietly;
157
+ * IdPs retry and expect idempotence.
158
+ */
159
+ patchGroup?(
160
+ tenantId: TenantId,
161
+ id: string,
162
+ patch: ScimGroupPatch,
163
+ ): Promise<Result<ScimGroupRecord>>
164
+
165
+ deleteGroup?(tenantId: TenantId, id: string): Promise<Result<void>>
166
+ }