@_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,180 @@
1
+ /**
2
+ * SP-initiated Single Logout — the outbound half.
3
+ *
4
+ * `POST /m/<methodId>/logout` (public, gated on `idp.sloUrl` like
5
+ * `/sls`). The **host** invokes this from its own logout UX for the
6
+ * **already-authenticated** subject, supplying the subject's SAML
7
+ * `NameID` (and, ideally, `SessionIndex`) — the values it received in
8
+ * `SamlSpProperties` at login. The library does not persist the
9
+ * NameID↔subject↔session mapping (the host owns it; see
10
+ * `ARCHITECTURE.md` §"onLogout"), so the host is the only party that
11
+ * can name who is logging out.
12
+ *
13
+ * Host contract / security:
14
+ * - This route only **propagates** logout to the upstream IdP. It
15
+ * does **not** revoke library tokens — OIDC session/token
16
+ * termination is `/end_session`'s job and is deliberately *not*
17
+ * auto-bridged. A complete host logout flow calls both.
18
+ * - It is `POST` (not `GET`) to keep a signed `LogoutRequest` off
19
+ * drive-by navigations / prefetch. It is still anonymous at the
20
+ * library boundary: the host MUST only render/trigger it for the
21
+ * authenticated subject, behind the host's own CSRF protection,
22
+ * with that subject's own `NameID`. A forced-logout via CSRF is a
23
+ * DoS-class risk the host owns (it owns the session); the library
24
+ * cannot authenticate the caller without owning a session it
25
+ * deliberately does not.
26
+ *
27
+ * The IdP processes the `LogoutRequest` and returns a `LogoutResponse`
28
+ * to `/sls` (the response leg — see `sls.ts`).
29
+ */
30
+ import { authError } from "../../types/error"
31
+ import type { MethodContext, MethodResult } from "../../types/method"
32
+
33
+ import {
34
+ buildSamlInstance,
35
+ resolveSpEntityId,
36
+ NAME_ID_FORMAT_URN,
37
+ } from "./saml-instance"
38
+ import type {
39
+ SamlNameIdFormat,
40
+ SamlSpConfig,
41
+ SamlSpProperties,
42
+ SamlSpState,
43
+ } from "./types"
44
+
45
+ export async function initiateSpLogout(
46
+ ctx: MethodContext<SamlSpState>,
47
+ methodId: string,
48
+ config: SamlSpConfig,
49
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
50
+ if (!ctx.dispatch) {
51
+ return {
52
+ kind: "error",
53
+ error: authError.internalError(
54
+ "saml-sp: /logout dispatched without issuer context (ctx.dispatch is null)",
55
+ ),
56
+ }
57
+ }
58
+ if (!config.idp.sloUrl) {
59
+ // Unreachable: /logout is only public when sloUrl is set.
60
+ return {
61
+ kind: "error",
62
+ error: authError.internalError(
63
+ "saml-sp: /logout reached with no idp.sloUrl configured",
64
+ ),
65
+ }
66
+ }
67
+
68
+ let nameId: string | null
69
+ let sessionIndex: string | null
70
+ let relayState: string | null
71
+ let nameIdFormatParam: string | null
72
+ try {
73
+ const form = new URLSearchParams(await ctx.request.text())
74
+ nameId = form.get("nameId")
75
+ sessionIndex = form.get("sessionIndex")
76
+ relayState = form.get("relayState")
77
+ nameIdFormatParam = form.get("nameIdFormat")
78
+ } catch {
79
+ return {
80
+ kind: "error",
81
+ error: authError.internalError(
82
+ "saml-sp: /logout could not read POST body",
83
+ ),
84
+ }
85
+ }
86
+
87
+ if (!nameId) {
88
+ // The host must supply the subject's SAML NameID — the library
89
+ // cannot derive it (it never persisted the mapping).
90
+ return { kind: "denied", reason: "missing nameId" }
91
+ }
92
+
93
+ // NameID format: explicit param wins; else the connection's
94
+ // configured format; else persistent (the SSO default). The param is
95
+ // a friendly key (same vocabulary as `config.idp.nameIdFormat`), not
96
+ // a raw URN — validate it rather than passing an arbitrary
97
+ // host-supplied string straight into the LogoutRequest, where a
98
+ // typo'd format silently produces a cryptic IdP-side rejection.
99
+ if (
100
+ nameIdFormatParam !== null &&
101
+ !(nameIdFormatParam in NAME_ID_FORMAT_URN)
102
+ ) {
103
+ return {
104
+ kind: "denied",
105
+ reason:
106
+ `unrecognized nameIdFormat "${nameIdFormatParam}" — expected one ` +
107
+ `of: ${Object.keys(NAME_ID_FORMAT_URN).join(", ")}`,
108
+ }
109
+ }
110
+ const nameIDFormat =
111
+ nameIdFormatParam !== null
112
+ ? NAME_ID_FORMAT_URN[nameIdFormatParam as SamlNameIdFormat]
113
+ : config.idp.nameIdFormat !== undefined
114
+ ? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
115
+ : NAME_ID_FORMAT_URN.persistent
116
+
117
+ const spEntityId = resolveSpEntityId(
118
+ config,
119
+ ctx.dispatch.issuerUrl,
120
+ ctx.tenant.id,
121
+ methodId,
122
+ )
123
+
124
+ let redirectUrl: string
125
+ try {
126
+ const saml = buildSamlInstance(
127
+ config,
128
+ {
129
+ spEntityId,
130
+ acsUrl: ctx.dispatch.callbackUrl,
131
+ scratch: ctx.methodScratch,
132
+ logoutUrl: config.idp.sloUrl,
133
+ logout: true,
134
+ // Sign the LogoutRequest whenever a per-connection SP key is
135
+ // configured — most IdPs require a signed SP-initiated logout.
136
+ ...(config.signingKey
137
+ ? {
138
+ signing: {
139
+ privateKeyPem: config.signingKey.privateKeyPem,
140
+ certPem: config.signingKey.certPem,
141
+ },
142
+ }
143
+ : {}),
144
+ },
145
+ Date.now(),
146
+ )
147
+ redirectUrl = await saml.getLogoutUrlAsync(
148
+ // node-saml's Profile shape; structurally compatible for request
149
+ // generation (it reads nameID / nameIDFormat / sessionIndex).
150
+ {
151
+ nameID: nameId,
152
+ nameIDFormat: nameIDFormat,
153
+ ...(sessionIndex !== null ? { sessionIndex } : {}),
154
+ } as never,
155
+ relayState ?? "",
156
+ {},
157
+ )
158
+ } catch (e) {
159
+ return {
160
+ kind: "error",
161
+ error: authError.internalError(
162
+ `saml-sp: failed to build LogoutRequest: ${
163
+ e instanceof Error ? e.message : String(e)
164
+ }`,
165
+ e,
166
+ ),
167
+ }
168
+ }
169
+
170
+ // Pure protocol propagation — no `logout` side effect (the host
171
+ // already tore down its session and, separately, calls /end_session
172
+ // for OIDC token revocation).
173
+ return {
174
+ kind: "challenge",
175
+ response: new Response(null, {
176
+ status: 302,
177
+ headers: { location: redirectUrl },
178
+ }),
179
+ }
180
+ }
@@ -0,0 +1,307 @@
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, resolveSpEntityId } 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 = resolveSpEntityId(
83
+ config,
84
+ ctx.dispatch.issuerUrl,
85
+ ctx.tenant.id,
86
+ methodId,
87
+ )
88
+
89
+ // Read both bindings: HTTP-Redirect (GET, signed query string) and
90
+ // HTTP-POST (form body). The raw query string is required to verify a
91
+ // redirect-binding signature (it is computed over the exact bytes).
92
+ const url = new URL(ctx.request.url)
93
+ const originalQuery = url.search.replace(/^\?/, "")
94
+ let samlRequest: string | null
95
+ let samlResponse: string | null
96
+ let relayState: string | null
97
+ const isPost = ctx.request.method.toUpperCase() === "POST"
98
+ let postForm: URLSearchParams | null = null
99
+ try {
100
+ if (isPost) {
101
+ postForm = new URLSearchParams(await ctx.request.text())
102
+ samlRequest = postForm.get("SAMLRequest")
103
+ samlResponse = postForm.get("SAMLResponse")
104
+ relayState = postForm.get("RelayState")
105
+ } else {
106
+ samlRequest = url.searchParams.get("SAMLRequest")
107
+ samlResponse = url.searchParams.get("SAMLResponse")
108
+ relayState = url.searchParams.get("RelayState")
109
+ }
110
+ } catch {
111
+ return {
112
+ kind: "error",
113
+ error: authError.internalError("saml-sp: /sls could not read request"),
114
+ }
115
+ }
116
+
117
+ if (!samlRequest && !samlResponse) {
118
+ return { kind: "denied", reason: "missing SAMLRequest / SAMLResponse" }
119
+ }
120
+
121
+ // Building the verifier is a configuration concern (e.g. no IdP
122
+ // signing cert within its validity window) — an operator fault, not a
123
+ // per-user auth failure. Sign logout messages whenever a per-
124
+ // connection SP key is configured (decoupled from `signAuthnRequest`,
125
+ // which is specifically about the AuthnRequest).
126
+ let saml: ReturnType<typeof buildSamlInstance>
127
+ try {
128
+ saml = buildSamlInstance(
129
+ config,
130
+ {
131
+ spEntityId,
132
+ acsUrl: ctx.dispatch.callbackUrl,
133
+ scratch: ctx.methodScratch,
134
+ logoutUrl: config.idp.sloUrl,
135
+ logout: true,
136
+ ...(config.signingKey
137
+ ? {
138
+ signing: {
139
+ privateKeyPem: config.signingKey.privateKeyPem,
140
+ certPem: config.signingKey.certPem,
141
+ },
142
+ }
143
+ : {}),
144
+ },
145
+ Date.now(),
146
+ )
147
+ } catch (e) {
148
+ return {
149
+ kind: "error",
150
+ error: authError.internalError(
151
+ `saml-sp: cannot construct logout verifier: ${
152
+ e instanceof Error ? e.message : String(e)
153
+ }`,
154
+ e,
155
+ ),
156
+ }
157
+ }
158
+
159
+ // ---- IdP-initiated LogoutRequest (the Phase 3 deliverable) --------
160
+ if (samlRequest) {
161
+ let profile: NodeSamlProfile | null
162
+ try {
163
+ const res = isPost
164
+ ? await saml.validatePostRequestAsync({
165
+ SAMLRequest: samlRequest,
166
+ ...(relayState !== null ? { RelayState: relayState } : {}),
167
+ })
168
+ : await saml.validateRedirectAsync(
169
+ Object.fromEntries(url.searchParams) as Record<string, string>,
170
+ originalQuery,
171
+ )
172
+ profile = res.profile as NodeSamlProfile | null
173
+ } catch (e) {
174
+ // Bad/absent signature, unknown issuer, malformed LogoutRequest —
175
+ // controlled, not a server fault. Same posture as the ACS.
176
+ return {
177
+ kind: "denied",
178
+ reason: `logout request rejected: ${
179
+ e instanceof Error ? e.message : String(e)
180
+ }`,
181
+ }
182
+ }
183
+ if (!profile) {
184
+ return { kind: "denied", reason: "logout request produced no profile" }
185
+ }
186
+ // Fail-closed: the replay guard below is load-bearing (it stops a
187
+ // captured validly-signed LogoutRequest from repeatedly driving
188
+ // onLogout/revokeAllForSubject). node-saml v5.1.0 already throws
189
+ // before returning a profile when `@ID` is absent, so this is
190
+ // unreachable for a conformant message — but asserting it here
191
+ // makes the documented fail-closed posture true in code rather
192
+ // than silently dependent on an upstream library invariant
193
+ // (mirrors the IdP-init assertion-replay handling in acs.ts).
194
+ if (!profile.ID) {
195
+ return {
196
+ kind: "error",
197
+ error: authError.internalError(
198
+ "saml-sp: LogoutRequest carries no @ID — front-channel replay " +
199
+ "protection cannot be established. Refusing to process.",
200
+ ),
201
+ }
202
+ }
203
+
204
+ // Front-channel replay dedup on the verified LogoutRequest @ID.
205
+ // Logout is idempotent so the blast radius is low, but a replayed
206
+ // signed message should not repeatedly drive revocation.
207
+ {
208
+ const key = `slo-replay:${profile.ID}`
209
+ const seen = await ctx.methodScratch.get(key)
210
+ if (seen.ok) {
211
+ return {
212
+ kind: "denied",
213
+ reason: "logout request replay detected (request ID already seen)",
214
+ }
215
+ }
216
+ const recorded = await ctx.methodScratch.put(
217
+ key,
218
+ "1",
219
+ SLO_REPLAY_HORIZON_MS,
220
+ )
221
+ if (isErr(recorded)) {
222
+ return {
223
+ kind: "error",
224
+ error: authError.internalError(
225
+ "saml-sp: could not record LogoutRequest ID for replay protection",
226
+ ),
227
+ }
228
+ }
229
+ }
230
+
231
+ // Emit the signed LogoutResponse redirect back to the IdP's SLO
232
+ // endpoint (success status — we always honour a validly-signed
233
+ // logout). node-saml uses `logoutUrl` (set above) as the target and
234
+ // echoes `InResponseTo` from the request profile.
235
+ let logoutResponseUrl: string
236
+ try {
237
+ logoutResponseUrl = await saml.getLogoutResponseUrlAsync(
238
+ // node-saml's Profile shape; our minimal view is structurally
239
+ // compatible for response generation (it reads ID / issuer).
240
+ profile as never,
241
+ relayState ?? "",
242
+ {},
243
+ true,
244
+ )
245
+ } catch (e) {
246
+ return {
247
+ kind: "error",
248
+ error: authError.internalError(
249
+ `saml-sp: failed to build LogoutResponse: ${
250
+ e instanceof Error ? e.message : String(e)
251
+ }`,
252
+ e,
253
+ ),
254
+ }
255
+ }
256
+
257
+ return {
258
+ kind: "challenge",
259
+ response: new Response(null, {
260
+ status: 302,
261
+ headers: { location: logoutResponseUrl },
262
+ }),
263
+ // Triggers the framework's onLogout side effect (host teardown +
264
+ // optional revokeAllForSubject) before the redirect is returned.
265
+ logout: {
266
+ ...(profile.nameID !== undefined ? { nameId: profile.nameID } : {}),
267
+ ...(profile.sessionIndex !== undefined
268
+ ? { sessionIndex: profile.sessionIndex }
269
+ : {}),
270
+ },
271
+ }
272
+ }
273
+
274
+ // ---- IdP LogoutResponse (SP-initiated completion) ----------------
275
+ // The SP-initiated *send* path + post-logout redirect lands in the
276
+ // next increment; here we validate the IdP confirmed logout and
277
+ // acknowledge. No `logout` side effect — SP-initiated revocation
278
+ // happens at the trigger, not on the return leg.
279
+ try {
280
+ if (isPost) {
281
+ await saml.validatePostResponseAsync({
282
+ SAMLResponse: samlResponse as string,
283
+ ...(relayState !== null ? { RelayState: relayState } : {}),
284
+ })
285
+ } else {
286
+ await saml.validateRedirectAsync(
287
+ Object.fromEntries(url.searchParams) as Record<string, string>,
288
+ originalQuery,
289
+ )
290
+ }
291
+ } catch (e) {
292
+ return {
293
+ kind: "denied",
294
+ reason: `logout response rejected: ${
295
+ e instanceof Error ? e.message : String(e)
296
+ }`,
297
+ }
298
+ }
299
+
300
+ return {
301
+ kind: "challenge",
302
+ response: new Response("Logged out.", {
303
+ status: 200,
304
+ headers: { "content-type": "text/plain" },
305
+ }),
306
+ }
307
+ }