@_mustachio/openauth 0.11.0 → 0.12.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 (164) 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/state-envelope.js +2 -2
  32. package/dist/esm/domain/token.js +5 -5
  33. package/dist/esm/error.js +8 -8
  34. package/dist/esm/http/cookies.js +19 -2
  35. package/dist/esm/http/errors.js +4 -4
  36. package/dist/esm/http/handlers/authorize.js +3 -19
  37. package/dist/esm/http/handlers/callback.js +7 -1
  38. package/dist/esm/http/handlers/metadata.js +2 -2
  39. package/dist/esm/http/handlers/method-route.js +47 -2
  40. package/dist/esm/http/handlers/revocation.js +2 -2
  41. package/dist/esm/http/middleware/tenant.js +6 -6
  42. package/dist/esm/http/schemas/authorize.js +2 -2
  43. package/dist/esm/http/schemas/revocation.js +2 -2
  44. package/dist/esm/http/schemas/token.js +7 -7
  45. package/dist/esm/index.js +33 -32
  46. package/dist/esm/methods/oauth2-factory.js +2 -2
  47. package/dist/esm/methods/password-hash.js +2 -2
  48. package/dist/esm/methods/providers/index.js +30 -14
  49. package/dist/esm/methods/saml-sp/acs.js +208 -0
  50. package/dist/esm/methods/saml-sp/attributes.js +75 -0
  51. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  52. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  53. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  54. package/dist/esm/methods/saml-sp/factory.js +70 -0
  55. package/dist/esm/methods/saml-sp/index.js +7 -0
  56. package/dist/esm/methods/saml-sp/metadata.js +69 -0
  57. package/dist/esm/methods/saml-sp/method.js +29 -0
  58. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  59. package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
  60. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  61. package/dist/esm/methods/saml-sp/sls.js +154 -0
  62. package/dist/esm/methods/saml-sp/types.js +0 -0
  63. package/dist/esm/types/result.js +3 -3
  64. package/dist/esm/ui/forms.js +3 -3
  65. package/dist/esm/ui/index.js +8 -5
  66. package/dist/esm/ui/picker.js +2 -2
  67. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  68. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  69. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  70. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  71. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  72. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  73. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  74. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  75. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  76. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  77. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  79. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  80. package/dist/types/domain/callback.d.ts +16 -1
  81. package/dist/types/domain/callback.d.ts.map +1 -1
  82. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  83. package/dist/types/domain/method-route.d.ts +57 -1
  84. package/dist/types/domain/method-route.d.ts.map +1 -1
  85. package/dist/types/http/context.d.ts +3 -1
  86. package/dist/types/http/context.d.ts.map +1 -1
  87. package/dist/types/http/cookies.d.ts +9 -1
  88. package/dist/types/http/cookies.d.ts.map +1 -1
  89. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  90. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  91. package/dist/types/index.d.ts +1 -1
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  94. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  95. package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
  96. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  97. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  98. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  99. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  100. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  101. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  102. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  103. package/dist/types/methods/saml-sp/factory.d.ts +704 -0
  104. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  105. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  106. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  107. package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
  108. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  109. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  110. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  111. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  112. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  113. package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
  114. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  115. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  116. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  117. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/types.d.ts +204 -0
  120. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  121. package/dist/types/ports/audit-log.d.ts +18 -5
  122. package/dist/types/ports/audit-log.d.ts.map +1 -1
  123. package/dist/types/ports/session-store.d.ts +23 -0
  124. package/dist/types/ports/session-store.d.ts.map +1 -1
  125. package/dist/types/types/idp.d.ts +71 -0
  126. package/dist/types/types/idp.d.ts.map +1 -1
  127. package/dist/types/types/method.d.ts +109 -0
  128. package/dist/types/types/method.d.ts.map +1 -1
  129. package/package.json +9 -1
  130. package/src/adapters/d1/migrations.ts +5 -0
  131. package/src/adapters/d1/session-store.ts +68 -0
  132. package/src/adapters/durable-object/session-store.ts +61 -0
  133. package/src/adapters/dynamo/session-store.ts +63 -0
  134. package/src/adapters/memory/session-store.ts +39 -0
  135. package/src/adapters/postgres/migrations.ts +6 -0
  136. package/src/adapters/postgres/session-store.ts +69 -0
  137. package/src/domain/callback.ts +215 -7
  138. package/src/domain/method-dispatch.ts +53 -1
  139. package/src/domain/method-route.ts +190 -1
  140. package/src/http/context.ts +6 -0
  141. package/src/http/cookies.ts +19 -1
  142. package/src/http/handlers/authorize.ts +2 -21
  143. package/src/http/handlers/callback.ts +9 -1
  144. package/src/http/handlers/method-route.ts +75 -2
  145. package/src/index.ts +3 -0
  146. package/src/methods/saml-sp/acs.ts +399 -0
  147. package/src/methods/saml-sp/attributes.ts +137 -0
  148. package/src/methods/saml-sp/authnrequest.ts +128 -0
  149. package/src/methods/saml-sp/cache-provider.ts +76 -0
  150. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  151. package/src/methods/saml-sp/factory.ts +118 -0
  152. package/src/methods/saml-sp/index.ts +34 -0
  153. package/src/methods/saml-sp/metadata.ts +204 -0
  154. package/src/methods/saml-sp/method.ts +65 -0
  155. package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
  156. package/src/methods/saml-sp/saml-instance.ts +173 -0
  157. package/src/methods/saml-sp/slo-initiate.ts +177 -0
  158. package/src/methods/saml-sp/sls.ts +306 -0
  159. package/src/methods/saml-sp/types.ts +203 -0
  160. package/src/ports/CONSISTENCY.md +25 -0
  161. package/src/ports/audit-log.ts +18 -5
  162. package/src/ports/session-store.ts +28 -0
  163. package/src/types/idp.ts +74 -0
  164. package/src/types/method.ts +110 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Single Logout Service — the inbound front-channel logout half.
3
+ *
4
+ * Mounted as a **public** (anonymous, flowless) route at
5
+ * `GET|POST /m/<methodId>/sls` (declared in `AuthMethod.publicRoutes`
6
+ * only when `SamlSpConfig.idp.sloUrl` is configured — advertising an
7
+ * SLS we cannot complete would break interop, mirroring how
8
+ * `unsolicitedCallback` is gated on `idpInitiated`).
9
+ *
10
+ * Authenticity here is **cryptographic, not cookie-based** — exactly
11
+ * the SAML front-channel SLO model. node-saml verifies the inbound
12
+ * message's XML-DSig against the pinned IdP cert (`SAML-AD1` — we do
13
+ * not reimplement signature checking):
14
+ *
15
+ * - **`SAMLRequest`** = an IdP-initiated `LogoutRequest`. Verify it,
16
+ * emit a signed `LogoutResponse` redirect back to the IdP's SLO
17
+ * endpoint, and return `MethodResult.challenge` carrying the
18
+ * verified `logout` intent so the framework fires
19
+ * `IdPOptions.onLogout` (host session teardown + optional
20
+ * `revokeAllForSubject`). The method stays port-free; the
21
+ * privileged side effect runs in the framework's public-route
22
+ * pipeline (see `ARCHITECTURE.md` §"onLogout + challenge.logout").
23
+ * - **`SAMLResponse`** = the IdP's `LogoutResponse` to an
24
+ * SP-initiated `LogoutRequest`. Validated and acknowledged here;
25
+ * the SP-initiated *send* path + post-logout redirect lands in the
26
+ * next increment.
27
+ *
28
+ * Verification failures are controlled `denied` (bad/absent signature,
29
+ * unknown issuer, malformed message) — the same classification
30
+ * `acs.ts` uses. Misconfiguration / infra faults are `error`.
31
+ */
32
+ import { authError } from "../../types/error"
33
+ import type { MethodContext, MethodResult } from "../../types/method"
34
+ import { isErr } from "../../types/result"
35
+
36
+ import { buildSamlInstance, deriveSpEntityId } from "./saml-instance"
37
+ import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
38
+
39
+ /** Minimal view of node-saml's `Profile` — never leaked publicly. */
40
+ type NodeSamlProfile = {
41
+ nameID?: string
42
+ sessionIndex?: string
43
+ /** The inbound message's `ID` — used for front-channel replay dedup. */
44
+ ID?: string
45
+ }
46
+
47
+ /**
48
+ * Replay-dedup horizon for a verified `LogoutRequest`. A SAML
49
+ * `LogoutRequest` usually carries no `NotOnOrAfter`, so unlike the
50
+ * assertion path there is no message-supplied expiry to track — a
51
+ * fixed, clamped window is sufficient (logout is idempotent; this only
52
+ * suppresses a signed-message replay storm).
53
+ */
54
+ const SLO_REPLAY_HORIZON_MS = 10 * 60_000
55
+
56
+ export async function consumeSls(
57
+ ctx: MethodContext<SamlSpState>,
58
+ methodId: string,
59
+ config: SamlSpConfig,
60
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
61
+ // Public route: no flow, but the HTTP layer supplies dispatch so we
62
+ // can derive the stable SP entityID (same derivation as AuthnRequest
63
+ // / ACS / metadata — no drift).
64
+ if (!ctx.dispatch) {
65
+ return {
66
+ kind: "error",
67
+ error: authError.internalError(
68
+ "saml-sp: /sls dispatched without issuer context (ctx.dispatch is null)",
69
+ ),
70
+ }
71
+ }
72
+ if (!config.idp.sloUrl) {
73
+ // Should be unreachable: /sls is only public when sloUrl is set.
74
+ return {
75
+ kind: "error",
76
+ error: authError.internalError(
77
+ "saml-sp: /sls reached with no idp.sloUrl configured",
78
+ ),
79
+ }
80
+ }
81
+
82
+ const spEntityId = deriveSpEntityId(
83
+ ctx.dispatch.issuerUrl,
84
+ ctx.tenant.id,
85
+ methodId,
86
+ )
87
+
88
+ // Read both bindings: HTTP-Redirect (GET, signed query string) and
89
+ // HTTP-POST (form body). The raw query string is required to verify a
90
+ // redirect-binding signature (it is computed over the exact bytes).
91
+ const url = new URL(ctx.request.url)
92
+ const originalQuery = url.search.replace(/^\?/, "")
93
+ let samlRequest: string | null
94
+ let samlResponse: string | null
95
+ let relayState: string | null
96
+ const isPost = ctx.request.method.toUpperCase() === "POST"
97
+ let postForm: URLSearchParams | null = null
98
+ try {
99
+ if (isPost) {
100
+ postForm = new URLSearchParams(await ctx.request.text())
101
+ samlRequest = postForm.get("SAMLRequest")
102
+ samlResponse = postForm.get("SAMLResponse")
103
+ relayState = postForm.get("RelayState")
104
+ } else {
105
+ samlRequest = url.searchParams.get("SAMLRequest")
106
+ samlResponse = url.searchParams.get("SAMLResponse")
107
+ relayState = url.searchParams.get("RelayState")
108
+ }
109
+ } catch {
110
+ return {
111
+ kind: "error",
112
+ error: authError.internalError("saml-sp: /sls could not read request"),
113
+ }
114
+ }
115
+
116
+ if (!samlRequest && !samlResponse) {
117
+ return { kind: "denied", reason: "missing SAMLRequest / SAMLResponse" }
118
+ }
119
+
120
+ // Building the verifier is a configuration concern (e.g. no IdP
121
+ // signing cert within its validity window) — an operator fault, not a
122
+ // per-user auth failure. Sign logout messages whenever a per-
123
+ // connection SP key is configured (decoupled from `signAuthnRequest`,
124
+ // which is specifically about the AuthnRequest).
125
+ let saml: ReturnType<typeof buildSamlInstance>
126
+ try {
127
+ saml = buildSamlInstance(
128
+ config,
129
+ {
130
+ spEntityId,
131
+ acsUrl: ctx.dispatch.callbackUrl,
132
+ scratch: ctx.methodScratch,
133
+ logoutUrl: config.idp.sloUrl,
134
+ logout: true,
135
+ ...(config.signingKey
136
+ ? {
137
+ signing: {
138
+ privateKeyPem: config.signingKey.privateKeyPem,
139
+ certPem: config.signingKey.certPem,
140
+ },
141
+ }
142
+ : {}),
143
+ },
144
+ Date.now(),
145
+ )
146
+ } catch (e) {
147
+ return {
148
+ kind: "error",
149
+ error: authError.internalError(
150
+ `saml-sp: cannot construct logout verifier: ${
151
+ e instanceof Error ? e.message : String(e)
152
+ }`,
153
+ e,
154
+ ),
155
+ }
156
+ }
157
+
158
+ // ---- IdP-initiated LogoutRequest (the Phase 3 deliverable) --------
159
+ if (samlRequest) {
160
+ let profile: NodeSamlProfile | null
161
+ try {
162
+ const res = isPost
163
+ ? await saml.validatePostRequestAsync({
164
+ SAMLRequest: samlRequest,
165
+ ...(relayState !== null ? { RelayState: relayState } : {}),
166
+ })
167
+ : await saml.validateRedirectAsync(
168
+ Object.fromEntries(url.searchParams) as Record<string, string>,
169
+ originalQuery,
170
+ )
171
+ profile = res.profile as NodeSamlProfile | null
172
+ } catch (e) {
173
+ // Bad/absent signature, unknown issuer, malformed LogoutRequest —
174
+ // controlled, not a server fault. Same posture as the ACS.
175
+ return {
176
+ kind: "denied",
177
+ reason: `logout request rejected: ${
178
+ e instanceof Error ? e.message : String(e)
179
+ }`,
180
+ }
181
+ }
182
+ if (!profile) {
183
+ return { kind: "denied", reason: "logout request produced no profile" }
184
+ }
185
+ // Fail-closed: the replay guard below is load-bearing (it stops a
186
+ // captured validly-signed LogoutRequest from repeatedly driving
187
+ // onLogout/revokeAllForSubject). node-saml v5.1.0 already throws
188
+ // before returning a profile when `@ID` is absent, so this is
189
+ // unreachable for a conformant message — but asserting it here
190
+ // makes the documented fail-closed posture true in code rather
191
+ // than silently dependent on an upstream library invariant
192
+ // (mirrors the IdP-init assertion-replay handling in acs.ts).
193
+ if (!profile.ID) {
194
+ return {
195
+ kind: "error",
196
+ error: authError.internalError(
197
+ "saml-sp: LogoutRequest carries no @ID — front-channel replay " +
198
+ "protection cannot be established. Refusing to process.",
199
+ ),
200
+ }
201
+ }
202
+
203
+ // Front-channel replay dedup on the verified LogoutRequest @ID.
204
+ // Logout is idempotent so the blast radius is low, but a replayed
205
+ // signed message should not repeatedly drive revocation.
206
+ {
207
+ const key = `slo-replay:${profile.ID}`
208
+ const seen = await ctx.methodScratch.get(key)
209
+ if (seen.ok) {
210
+ return {
211
+ kind: "denied",
212
+ reason: "logout request replay detected (request ID already seen)",
213
+ }
214
+ }
215
+ const recorded = await ctx.methodScratch.put(
216
+ key,
217
+ "1",
218
+ SLO_REPLAY_HORIZON_MS,
219
+ )
220
+ if (isErr(recorded)) {
221
+ return {
222
+ kind: "error",
223
+ error: authError.internalError(
224
+ "saml-sp: could not record LogoutRequest ID for replay protection",
225
+ ),
226
+ }
227
+ }
228
+ }
229
+
230
+ // Emit the signed LogoutResponse redirect back to the IdP's SLO
231
+ // endpoint (success status — we always honour a validly-signed
232
+ // logout). node-saml uses `logoutUrl` (set above) as the target and
233
+ // echoes `InResponseTo` from the request profile.
234
+ let logoutResponseUrl: string
235
+ try {
236
+ logoutResponseUrl = await saml.getLogoutResponseUrlAsync(
237
+ // node-saml's Profile shape; our minimal view is structurally
238
+ // compatible for response generation (it reads ID / issuer).
239
+ profile as never,
240
+ relayState ?? "",
241
+ {},
242
+ true,
243
+ )
244
+ } catch (e) {
245
+ return {
246
+ kind: "error",
247
+ error: authError.internalError(
248
+ `saml-sp: failed to build LogoutResponse: ${
249
+ e instanceof Error ? e.message : String(e)
250
+ }`,
251
+ e,
252
+ ),
253
+ }
254
+ }
255
+
256
+ return {
257
+ kind: "challenge",
258
+ response: new Response(null, {
259
+ status: 302,
260
+ headers: { location: logoutResponseUrl },
261
+ }),
262
+ // Triggers the framework's onLogout side effect (host teardown +
263
+ // optional revokeAllForSubject) before the redirect is returned.
264
+ logout: {
265
+ ...(profile.nameID !== undefined ? { nameId: profile.nameID } : {}),
266
+ ...(profile.sessionIndex !== undefined
267
+ ? { sessionIndex: profile.sessionIndex }
268
+ : {}),
269
+ },
270
+ }
271
+ }
272
+
273
+ // ---- IdP LogoutResponse (SP-initiated completion) ----------------
274
+ // The SP-initiated *send* path + post-logout redirect lands in the
275
+ // next increment; here we validate the IdP confirmed logout and
276
+ // acknowledge. No `logout` side effect — SP-initiated revocation
277
+ // happens at the trigger, not on the return leg.
278
+ try {
279
+ if (isPost) {
280
+ await saml.validatePostResponseAsync({
281
+ SAMLResponse: samlResponse as string,
282
+ ...(relayState !== null ? { RelayState: relayState } : {}),
283
+ })
284
+ } else {
285
+ await saml.validateRedirectAsync(
286
+ Object.fromEntries(url.searchParams) as Record<string, string>,
287
+ originalQuery,
288
+ )
289
+ }
290
+ } catch (e) {
291
+ return {
292
+ kind: "denied",
293
+ reason: `logout response rejected: ${
294
+ e instanceof Error ? e.message : String(e)
295
+ }`,
296
+ }
297
+ }
298
+
299
+ return {
300
+ kind: "challenge",
301
+ response: new Response("Logged out.", {
302
+ status: 200,
303
+ headers: { "content-type": "text/plain" },
304
+ }),
305
+ }
306
+ }
@@ -0,0 +1,203 @@
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
+
157
+ /**
158
+ * Method-private state stashed in `FlowRecord.methodState` for the
159
+ * duration of an SP-initiated flow.
160
+ *
161
+ * `InResponseTo` correlation is handled out-of-band by node-saml's
162
+ * `CacheProvider` (backed by `methodScratch`), so the outstanding
163
+ * request id does **not** live here — only the framework state
164
+ * envelope echoed as RelayState and the issuance timestamp.
165
+ */
166
+ export type SamlSpState = {
167
+ relayState: string
168
+ issuedAt: number
169
+ /**
170
+ * SP entityID + ACS URL computed at AuthnRequest time, where the
171
+ * framework's dispatch context is available. The ACS dispatch has
172
+ * no dispatch context, so these are read back from here to validate
173
+ * the assertion's `AudienceRestriction` / `Recipient` against the
174
+ * exact values the IdP saw in the request.
175
+ */
176
+ spEntityId: string
177
+ acsUrl: string
178
+ }
179
+
180
+ /**
181
+ * Properties handed to the host's `IdPOptions.success` callback on a
182
+ * successful SAML authentication. The host translates these into the
183
+ * final `SubjectClaim` it owns.
184
+ *
185
+ * `raw.responseXml` is provided as an escape hatch for hosts that want
186
+ * to inspect the verified Response themselves; the wrapper has already
187
+ * run the full signature gauntlet by the time these properties leave
188
+ * the method.
189
+ */
190
+ export type SamlSpProperties = {
191
+ nameId: {
192
+ value: string
193
+ format: SamlNameIdFormat
194
+ }
195
+ attributes: Record<string, string | string[]>
196
+ /** Used for Single Logout correlation (Phase 3). */
197
+ sessionIndex?: string
198
+ /** Unix ms — the assertion's `AuthnInstant`. */
199
+ authnInstant: number
200
+ raw: {
201
+ responseXml: string
202
+ }
203
+ }
@@ -30,6 +30,7 @@ overspecifies adapters that don't need that on the read-eventual paths
30
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
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
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. |
33
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`. |
34
35
  | `KeyStore` | `currentSigningKey()` / `currentEncryptionKey()` | Strong. | Active key must be unambiguous. |
35
36
  | `KeyStore` | `signingKeys()` (JWKS) | Eventual OK (with TTL). | Verifiers tolerate brief JWKS lag during rotation. |
@@ -64,6 +65,12 @@ demonstrate, under simulated replication lag:
64
65
  2. `consumeRefresh` CAS resolves to exactly one winner under concurrent
65
66
  attempts on the same token.
66
67
  3. `revokeBySubject` propagates within the documented SLA.
68
+ 4. `readScratch` immediately after the matching `saveScratch` returns the
69
+ value. Scratch backs SAML SP InResponseTo replay protection: the
70
+ AuthnRequest is correlated at the ACS by a scratch read that must see
71
+ the write made when the request was issued. The D1 adapter pins all
72
+ three scratch operations to the primary via the Sessions API
73
+ (`primarySession`), the same path `consumeFlow` uses.
67
74
 
68
75
  Read-eventual paths (`ConfigStore.getTenantConfig`, JWKS) may use replicas
69
76
  freely.
@@ -93,6 +100,11 @@ The fixture set covers, at minimum:
93
100
  one-shot consume atomicity, expiry enforcement, ttl-0 rejection.
94
101
  - **DPoP jti (`recordDpopJti`)** — first record succeeds, replay within
95
102
  TTL fails with `invalid_grant`, post-TTL the slot is freed for reuse.
103
+ - **Method scratch (`supportsScratch: true`)** — `saveScratch` /
104
+ `readScratch` / `deleteScratch` round-trip, overwrite semantics on
105
+ same key, expiry enforcement, idempotent `deleteScratch`, ttl-0
106
+ rejection. Keys are opaque to the adapter (the framework scopes them
107
+ upstream); a single test confirms that distinct keys are isolated.
96
108
 
97
109
  ## Optional methods and graceful degradation
98
110
 
@@ -110,6 +122,19 @@ caught up yet:
110
122
  does not support DPoP replay protection"`. Memory adapter
111
123
  implements; production adapters add it the same way `consumeRefresh`
112
124
  uses an atomic write-once primitive.
125
+ - `SessionStore.saveScratch / readScratch / deleteScratch` — required
126
+ for methods that hold cross-flow per-instance state (SAML SP replay
127
+ protection is the first user). Without them,
128
+ `MethodContext.methodScratch.put/get/delete` returns an
129
+ `internal_error` whose description names the missing operation.
130
+ Memory **and all four production adapters** (Postgres, D1, DynamoDB,
131
+ Durable Object) implement the trio as a TTL-respecting key/value
132
+ store keyed by an opaque string; each is opted into the
133
+ `supportsScratch` conformance cases. Semantics are upsert /
134
+ TTL-filtered read / idempotent delete — there is no atomic
135
+ delete-on-read (unlike `consumeFlow`). On DynamoDB the row also
136
+ carries a native `ttl` attribute for backstop eviction, but reads
137
+ still filter on the adapter clock because native TTL is best-effort.
113
138
 
114
139
  The framework never advertises a feature in discovery that the wired
115
140
  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
  /**
@@ -101,6 +101,34 @@ export type SessionStore = {
101
101
  ttl: number,
102
102
  ): Promise<Result<void>>
103
103
  consumePar?(requestUri: string): Promise<Result<ParRecord>>
104
+
105
+ /**
106
+ * Optional: per-method-instance scratch storage. Survives across flows
107
+ * (unlike `methodState`, which is per-flow). Use cases include cross-flow
108
+ * deduplication state — e.g., a SAML SP method remembering recently-seen
109
+ * assertion IDs for replay protection.
110
+ *
111
+ * The framework scopes keys per `(tenantId, methodId)` before calling
112
+ * these — adapters see opaque, already-namespaced keys and store the
113
+ * UTF-8 string value verbatim. Methods JSON-encode if they want object
114
+ * state.
115
+ *
116
+ * Strong consistency + TTL respect (same semantics as flow records).
117
+ * `readScratch` returns `unknown_state` if the key is missing or
118
+ * expired. `deleteScratch` is idempotent.
119
+ *
120
+ * Adapters without these methods cannot host methods that depend on
121
+ * scratch; the framework surfaces a clear `unsupported` error through
122
+ * `MethodContext.methodScratch` at call time. Implement all three
123
+ * methods together — partial implementations are not supported.
124
+ */
125
+ saveScratch?(
126
+ key: string,
127
+ value: string,
128
+ ttlMs: number,
129
+ ): Promise<Result<void>>
130
+ readScratch?(key: string): Promise<Result<string>>
131
+ deleteScratch?(key: string): Promise<Result<void>>
104
132
  }
105
133
 
106
134
  /**