@_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
@@ -32,7 +32,47 @@ import {
32
32
  authorizeDirectErrorResponse,
33
33
  tokenEndpointErrorResponse,
34
34
  } from "../errors"
35
- import { authError } from "../../types/error"
35
+ import { authError, type AuthError } from "../../types/error"
36
+
37
+ /**
38
+ * SCIM has its own media type and error envelope, and its own
39
+ * disclosure rule: an unauthenticated caller must not be able to tell an
40
+ * unknown tenant from a known one. The shared OAuth error response
41
+ * defeats both — it answers `400 application/json` with the
42
+ * tenant-not-found text, which is visibly different from the `403` a
43
+ * configured-but-SCIM-disabled tenant gets.
44
+ *
45
+ * So SCIM paths collapse every tenant-resolution failure onto the same
46
+ * `403` the domain layer emits for "SCIM is not enabled here". A store
47
+ * failure is still distinguishable as a 500, because that is an
48
+ * operational fault rather than a fact about which tenants exist.
49
+ */
50
+ function isScimPath(pathname: string): boolean {
51
+ return pathname === "/scim/v2" || pathname.startsWith("/scim/v2/")
52
+ }
53
+
54
+ function scimTenantErrorResponse(error: AuthError): Response {
55
+ const operational =
56
+ error.code === "server_error" || error.code === "internal_error"
57
+ const status = operational ? 500 : 403
58
+ const detail = operational
59
+ ? "the configuration store backing this endpoint failed the request"
60
+ : "SCIM provisioning is not enabled for this tenant"
61
+ return new Response(
62
+ JSON.stringify({
63
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
64
+ status: String(status),
65
+ detail,
66
+ }),
67
+ {
68
+ status,
69
+ headers: {
70
+ "content-type": "application/scim+json;charset=utf-8",
71
+ "cache-control": "no-store",
72
+ },
73
+ },
74
+ )
75
+ }
36
76
 
37
77
  function isCallbackPath(pathname: string): boolean {
38
78
  return pathname.startsWith("/cb/")
@@ -89,6 +129,9 @@ export function tenantMiddleware(deps: HttpDeps): MiddlewareHandler<HttpEnv> {
89
129
  if (url.pathname === "/authorize") {
90
130
  return authorizeDirectErrorResponse(resolved.error)
91
131
  }
132
+ if (isScimPath(url.pathname)) {
133
+ return scimTenantErrorResponse(resolved.error)
134
+ }
92
135
  return tokenEndpointErrorResponse(resolved.error)
93
136
  }
94
137
  const tenantId = resolved.value
@@ -101,6 +144,9 @@ export function tenantMiddleware(deps: HttpDeps): MiddlewareHandler<HttpEnv> {
101
144
  if (url.pathname === "/authorize") {
102
145
  return authorizeDirectErrorResponse(err)
103
146
  }
147
+ if (isScimPath(url.pathname)) {
148
+ return scimTenantErrorResponse(cfg.error)
149
+ }
104
150
  return tokenEndpointErrorResponse(err)
105
151
  }
106
152
  const custom = deps.buildCustomContext
@@ -24,6 +24,7 @@ import { makeDiscoveryHandler, makeJwksHandler } from "./handlers/metadata"
24
24
  import { makeMethodRouteHandler } from "./handlers/method-route"
25
25
  import { makeParHandler } from "./handlers/par"
26
26
  import { makeRegisterHandler } from "./handlers/register"
27
+ import { makeScimHandler } from "./handlers/scim"
27
28
  import { makeIntrospectHandler, makeRevokeHandler } from "./handlers/revocation"
28
29
  import { makeTokenHandler } from "./handlers/token"
29
30
  import { makeUserinfoHandler } from "./handlers/userinfo"
@@ -71,6 +72,14 @@ export function buildRouter(deps: HttpDeps): Hono<HttpEnv> {
71
72
  app.get("/end_session", makeEndSessionHandler(deps))
72
73
  app.post("/end_session", makeEndSessionHandler(deps))
73
74
 
75
+ // SCIM 2.0 provisioning. Tenant resolved by the standard middleware;
76
+ // the per-connection bearer token is then verified against that
77
+ // tenant's config in the domain layer (SCIM-AD4). A single `all`
78
+ // mount keeps Hono out of SCIM's routing, which lives in
79
+ // `domain/scim/handle.ts`.
80
+ app.use("/scim/v2/*", tenantMiddleware(deps))
81
+ app.all("/scim/v2/*", makeScimHandler(deps))
82
+
74
83
  // RFC 9126 Pushed Authorization Requests.
75
84
  app.use("/par", tenantMiddleware(deps))
76
85
  app.post("/par", makeParHandler(deps))
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export type {
76
76
  FailureEvent,
77
77
  IdP,
78
78
  IdPOptions,
79
+ LogoutEventInput,
80
+ LogoutHookResult,
79
81
  PersistUpstreamTokens,
80
82
  RegisterClient,
81
83
  RegisterClientRequest,
@@ -96,6 +98,25 @@ export type {
96
98
  SigningKey,
97
99
  } from "./ports/key-store"
98
100
  export type { MethodStore } from "./ports/method-store"
101
+ export type { ScimDirectory } from "./ports/scim-directory"
102
+ export type {
103
+ ScimConfig,
104
+ ScimEnterpriseUser,
105
+ ScimGroupMember,
106
+ ScimGroupPatch,
107
+ ScimGroupQuery,
108
+ ScimGroupRecord,
109
+ ScimGroupWrite,
110
+ ScimFilter,
111
+ ScimFilterAttribute,
112
+ ScimMultiValue,
113
+ ScimName,
114
+ ScimPage,
115
+ ScimUserPatch,
116
+ ScimUserQuery,
117
+ ScimUserRecord,
118
+ ScimUserWrite,
119
+ } from "./types/scim"
99
120
  export type {
100
121
  ParRecord,
101
122
  SessionRecord,
@@ -239,6 +260,7 @@ export function createIdP(opts: IdPOptions): IdP {
239
260
  sessionStore: opts.sessionStore,
240
261
  keyStore: opts.keyStore,
241
262
  ...(opts.methodStore ? { methodStore: opts.methodStore } : {}),
263
+ ...(opts.scimDirectory ? { scimDirectory: opts.scimDirectory } : {}),
242
264
  ...(auditLog ? { auditLog } : {}),
243
265
  methodCache,
244
266
  stateKeys: opts.stateKeys,
@@ -246,6 +268,7 @@ export function createIdP(opts: IdPOptions): IdP {
246
268
  ...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
247
269
  resolveTenant: opts.resolveTenant,
248
270
  success: opts.success,
271
+ ...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
249
272
  ...(opts.persistUpstreamTokens
250
273
  ? { persistUpstreamTokens: opts.persistUpstreamTokens }
251
274
  : {}),
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Assertion Consumer Service — the inbound, security-critical half.
3
+ *
4
+ * Mounted at the `"GET /callback"` route key (the framework's
5
+ * universal `/cb/<methodId>` callback, GET+POST). By the time this
6
+ * runs, `handleCallback` has already MAC-verified the state envelope
7
+ * (carried as `RelayState`) and consumed the flow; `ctx.flow` is the
8
+ * consumed record and `ctx.methodState` carries the SP entityID + ACS
9
+ * URL we committed to at AuthnRequest time.
10
+ *
11
+ * The cryptographic gauntlet (XML-DSig verification on signed
12
+ * references only, XSW/XXE resistance, issuer/audience,
13
+ * NotBefore/NotOnOrAfter, InResponseTo single-use) is delegated to
14
+ * `@node-saml/node-saml`'s `validatePostResponseAsync` — the
15
+ * CVE-2025-54369/54419-hardened path (SAML-AD1). We do not
16
+ * reimplement XML-DSig. Any verification failure surfaces as a thrown
17
+ * error which we map to a controlled `denied`; only infrastructure /
18
+ * misconfiguration faults become `error`.
19
+ *
20
+ * `SubjectConfirmationData/@Recipient` (gauntlet item 6) is **not**
21
+ * enforced by node-saml, so it is checked here explicitly against the
22
+ * signed assertion — see `checkRecipient`.
23
+ *
24
+ * Two modes, discriminated by `ctx.flow`:
25
+ *
26
+ * - **SP-initiated** (`ctx.flow` set): `handleCallback` MAC-verified
27
+ * the state envelope and consumed the flow; binding (SP entityID +
28
+ * ACS) comes from `ctx.methodState` committed at AuthnRequest time.
29
+ * `InResponseTo` is single-use (`always` + the scratch cache), so a
30
+ * replayed Response fails on its already-consumed request id.
31
+ * - **IdP-initiated** (`ctx.flow === null`): an unsolicited Response,
32
+ * no AuthnRequest, no state envelope, no flow. Allowed only when
33
+ * the instance configured `idpInitiated` (the framework gates this
34
+ * via `AuthMethod.unsolicitedCallback`). Binding is *derived* from
35
+ * `ctx.dispatch` (issuer/ACS — same derivation as AuthnRequest /
36
+ * metadata, so no drift). `InResponseTo` is `ifPresent` (none
37
+ * exists), so single-use no longer covers replay — we add explicit
38
+ * **assertion-ID dedup** via `methodScratch` (TTL = the assertion's
39
+ * `NotOnOrAfter` + skew). Success carries `unsolicitedBinding` from
40
+ * `config.idpInitiated` for the framework to mint the code.
41
+ */
42
+ // CJS interop per the SAML house-style note — default-import then
43
+ // destructure rather than relying on the named-export heuristic.
44
+ import xmldom from "@xmldom/xmldom"
45
+
46
+ import { authError } from "../../types/error"
47
+ import type { MethodContext, MethodResult } from "../../types/method"
48
+
49
+ import { mapProfile, type VerifiedProfile } from "./attributes"
50
+ import { buildSamlInstance, resolveSpEntityId } from "./saml-instance"
51
+ import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
52
+
53
+ const { DOMParser } = xmldom
54
+
55
+ type NodeSamlProfile = {
56
+ nameID?: string
57
+ nameIDFormat?: string
58
+ sessionIndex?: string
59
+ attributes?: Record<string, unknown>
60
+ getSamlResponseXml?: () => string
61
+ /** The signed assertion XML — the bytes xml-crypto verified. */
62
+ getAssertionXml?: () => string
63
+ }
64
+
65
+ /**
66
+ * Gauntlet item 6 — `SubjectConfirmationData/@Recipient`.
67
+ *
68
+ * node-saml enforces Issuer, AudienceRestriction,
69
+ * Conditions/SubjectConfirmation timestamps and `InResponseTo`, but it
70
+ * does **not** validate `@Recipient`. The SAML 2.0 Web Browser SSO
71
+ * profile (§4.1.4.3) requires a bearer `SubjectConfirmationData` whose
72
+ * `Recipient` is the ACS the assertion was delivered to. We read it
73
+ * from the **signed** assertion (`getAssertionXml()` — the verified
74
+ * bytes, never the unsigned outer Response) and compare to the exact
75
+ * ACS URL committed at AuthnRequest time. A real xml-crypto-verified
76
+ * DOM parse, not an xml2js-shape walk (which the plan rejected as more
77
+ * fragile than the documented gap).
78
+ *
79
+ * Returns `null` when the check passes; otherwise a non-`success`
80
+ * `MethodResult` (`denied` for a binding failure, `error` for a
81
+ * library/infra fault — matching the rest of this handler's
82
+ * classification).
83
+ */
84
+ function parseVerifiedAssertion(
85
+ profile: NodeSamlProfile,
86
+ ):
87
+ | { doc: Document }
88
+ | { failure: MethodResult<SamlSpProperties, SamlSpState> } {
89
+ if (typeof profile.getAssertionXml !== "function") {
90
+ // node-saml v5.1 populates this on a successful verify. Its
91
+ // absence means the library contract changed under us — fail loud
92
+ // rather than skip security checks we now promise.
93
+ return {
94
+ failure: {
95
+ kind: "error",
96
+ error: authError.internalError(
97
+ "saml-sp: node-saml profile exposes no getAssertionXml(); cannot " +
98
+ "perform the Recipient binding check. Refusing to authenticate.",
99
+ ),
100
+ },
101
+ }
102
+ }
103
+ try {
104
+ return {
105
+ doc: new DOMParser().parseFromString(
106
+ profile.getAssertionXml(),
107
+ "text/xml",
108
+ ) as unknown as Document,
109
+ }
110
+ } catch (e) {
111
+ return {
112
+ failure: {
113
+ kind: "error",
114
+ error: authError.internalError(
115
+ "saml-sp: failed to parse the verified assertion",
116
+ e,
117
+ ),
118
+ },
119
+ }
120
+ }
121
+ }
122
+
123
+ function checkRecipient(
124
+ doc: Document,
125
+ acsUrl: string,
126
+ ): MethodResult<SamlSpProperties, SamlSpState> | null {
127
+ // Namespace-agnostic: match by local name regardless of prefix.
128
+ const nodes = doc.getElementsByTagNameNS("*", "SubjectConfirmationData")
129
+ const recipients: string[] = []
130
+ for (let i = 0; i < nodes.length; i++) {
131
+ const r = nodes[i]?.getAttribute("Recipient")
132
+ if (r) recipients.push(r)
133
+ }
134
+
135
+ if (recipients.length === 0) {
136
+ return {
137
+ kind: "denied",
138
+ reason:
139
+ "assertion has no SubjectConfirmationData/@Recipient binding it " +
140
+ "to this ACS",
141
+ }
142
+ }
143
+ if (!recipients.includes(acsUrl)) {
144
+ return {
145
+ kind: "denied",
146
+ reason: "recipient mismatch: assertion not addressed to this ACS",
147
+ }
148
+ }
149
+ return null
150
+ }
151
+
152
+ /**
153
+ * For IdP-initiated replay dedup: pull the assertion `@ID` and the
154
+ * tightest `NotOnOrAfter` from the **verified** assertion (the same
155
+ * parsed document every other check reads). Returns `null` when the
156
+ * assertion carries no `@ID` — the caller treats that as a fail-loud
157
+ * error rather than skipping replay protection we promised.
158
+ */
159
+ function extractReplayInfo(
160
+ doc: Document,
161
+ ): { assertionId: string; notOnOrAfterMs: number | null } | null {
162
+ const root = doc.documentElement
163
+ const assertionId = root?.getAttribute("ID") ?? ""
164
+ if (!assertionId) return null
165
+ let earliest: number | null = null
166
+ for (const tag of ["Conditions", "SubjectConfirmationData"]) {
167
+ const els = doc.getElementsByTagNameNS("*", tag)
168
+ for (let i = 0; i < els.length; i++) {
169
+ const v = els[i]?.getAttribute("NotOnOrAfter")
170
+ if (!v) continue
171
+ const ms = Date.parse(v)
172
+ if (!Number.isNaN(ms))
173
+ earliest = earliest === null ? ms : Math.min(earliest, ms)
174
+ }
175
+ }
176
+ return { assertionId, notOnOrAfterMs: earliest }
177
+ }
178
+
179
+ /**
180
+ * Read the `<AuthnStatement>` facts the host needs but node-saml's
181
+ * `Profile` does not carry (`types.d.ts` exposes `nameID`,
182
+ * `nameIDFormat`, `sessionIndex` and the flattened attributes, and
183
+ * nothing else structural).
184
+ *
185
+ * Read from the **verified** assertion document — never the unsigned
186
+ * outer Response — for the same reason `checkRecipient` is: an
187
+ * attacker-controlled value outside the signature is not a fact.
188
+ *
189
+ * All three are optional in SAML, so every field is best-effort; the
190
+ * caller falls back rather than failing, since none of them is a
191
+ * security control on its own.
192
+ */
193
+ function extractAuthnStatement(doc: Document): {
194
+ authnInstant?: string
195
+ sessionNotOnOrAfter?: number
196
+ authnContextClassRef?: string
197
+ } {
198
+ const out: {
199
+ authnInstant?: string
200
+ sessionNotOnOrAfter?: number
201
+ authnContextClassRef?: string
202
+ } = {}
203
+ const stmt = doc.getElementsByTagNameNS("*", "AuthnStatement")[0]
204
+ if (!stmt) return out
205
+
206
+ const instant = stmt.getAttribute("AuthnInstant")
207
+ if (instant) out.authnInstant = instant
208
+
209
+ const sessionExpiry = stmt.getAttribute("SessionNotOnOrAfter")
210
+ if (sessionExpiry) {
211
+ const ms = Date.parse(sessionExpiry)
212
+ if (!Number.isNaN(ms)) out.sessionNotOnOrAfter = ms
213
+ }
214
+
215
+ const ref = stmt
216
+ .getElementsByTagNameNS("*", "AuthnContextClassRef")[0]
217
+ ?.textContent?.trim()
218
+ if (ref) out.authnContextClassRef = ref
219
+
220
+ return out
221
+ }
222
+
223
+ export async function consumeAssertion(
224
+ ctx: MethodContext<SamlSpState>,
225
+ methodId: string,
226
+ config: SamlSpConfig,
227
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
228
+ // Discriminator: a consumed flow ⇒ SP-initiated; no flow ⇒ an
229
+ // unsolicited IdP-initiated POST (the framework only routes one here
230
+ // when this instance opted in via `unsolicitedCallback`).
231
+ const idpInitiated = ctx.flow === null
232
+
233
+ let spEntityId: string
234
+ let acsUrl: string
235
+ if (idpInitiated) {
236
+ if (!config.idpInitiated) {
237
+ // Fail-closed: should be unreachable (the framework gates on
238
+ // `unsolicitedCallback`, set only when idpInitiated is config'd).
239
+ return {
240
+ kind: "error",
241
+ error: authError.internalError(
242
+ "saml-sp: unsolicited Response reached a method with no " +
243
+ "idpInitiated config",
244
+ ),
245
+ }
246
+ }
247
+ if (!ctx.dispatch) {
248
+ return {
249
+ kind: "error",
250
+ error: authError.internalError(
251
+ "saml-sp: IdP-initiated ACS dispatched without issuer context",
252
+ ),
253
+ }
254
+ }
255
+ // Same derivation as AuthnRequest / metadata — no drift.
256
+ spEntityId = resolveSpEntityId(
257
+ config,
258
+ ctx.dispatch.issuerUrl,
259
+ ctx.tenant.id,
260
+ methodId,
261
+ )
262
+ acsUrl = ctx.dispatch.callbackUrl
263
+ } else {
264
+ const state = ctx.methodState
265
+ if (!state || !state.spEntityId || !state.acsUrl) {
266
+ return {
267
+ kind: "error",
268
+ error: authError.internalError(
269
+ "saml-sp: ACS reached without AuthnRequest method state " +
270
+ "(spEntityId / acsUrl). The flow did not originate from this method.",
271
+ ),
272
+ }
273
+ }
274
+ spEntityId = state.spEntityId
275
+ acsUrl = state.acsUrl
276
+ }
277
+
278
+ let samlResponse: string | null
279
+ let relayState: string | null
280
+ try {
281
+ const form = new URLSearchParams(await ctx.request.text())
282
+ samlResponse = form.get("SAMLResponse")
283
+ relayState = form.get("RelayState")
284
+ } catch {
285
+ return {
286
+ kind: "error",
287
+ error: authError.internalError("saml-sp: ACS could not read POST body"),
288
+ }
289
+ }
290
+ if (!samlResponse) {
291
+ return { kind: "denied", reason: "missing SAMLResponse" }
292
+ }
293
+
294
+ // Build is a configuration concern (e.g. no signing cert within its
295
+ // validity window, or a rotation gap). A throw here is an operator
296
+ // fault, NOT a user auth failure — surfacing it as `denied` would
297
+ // hide a misconfiguration behind per-user "access denied" noise.
298
+ let saml: ReturnType<typeof buildSamlInstance>
299
+ try {
300
+ saml = buildSamlInstance(
301
+ config,
302
+ {
303
+ spEntityId,
304
+ acsUrl,
305
+ scratch: ctx.methodScratch,
306
+ ...(idpInitiated ? { idpInitiated: true } : {}),
307
+ // Encrypted assertions are opt-in per connection. Absent ⇒ no
308
+ // decryptionPvk ⇒ node-saml rejects an EncryptedAssertion.
309
+ ...(config.allowEncryptedAssertions && config.decryptionKey
310
+ ? { decryptionPvk: config.decryptionKey.privateKeyPem }
311
+ : {}),
312
+ },
313
+ Date.now(),
314
+ )
315
+ } catch (e) {
316
+ return {
317
+ kind: "error",
318
+ error: authError.internalError(
319
+ `saml-sp: cannot construct verifier: ${
320
+ e instanceof Error ? e.message : String(e)
321
+ }`,
322
+ e,
323
+ ),
324
+ }
325
+ }
326
+
327
+ let profile: NodeSamlProfile | null
328
+ try {
329
+ const result = await saml.validatePostResponseAsync({
330
+ SAMLResponse: samlResponse,
331
+ ...(relayState !== null ? { RelayState: relayState } : {}),
332
+ })
333
+ profile = result.profile as NodeSamlProfile | null
334
+ } catch (e) {
335
+ // Every node-saml verification failure (bad/absent signature,
336
+ // signature-wrapping, issuer/audience mismatch, expired
337
+ // conditions, unknown InResponseTo) throws here. These are
338
+ // controlled auth failures, not server faults.
339
+ const msg = e instanceof Error ? e.message : String(e)
340
+ // node-saml throws this exact message when an EncryptedAssertion
341
+ // arrives but no decryptionPvk is configured. Give the operator a
342
+ // signal that the *connection* is mis/under-configured rather than
343
+ // a generic "assertion rejected".
344
+ if (!config.allowEncryptedAssertions && msg.includes("No decryption key")) {
345
+ return {
346
+ kind: "denied",
347
+ reason:
348
+ "encrypted assertion received but encrypted assertions are not " +
349
+ "enabled for this SAML connection (set allowEncryptedAssertions " +
350
+ "+ decryptionKey)",
351
+ }
352
+ }
353
+ return { kind: "denied", reason: `assertion rejected: ${msg}` }
354
+ }
355
+
356
+ if (!profile || !profile.nameID) {
357
+ return { kind: "denied", reason: "assertion produced no usable subject" }
358
+ }
359
+
360
+ // One parse of the verified assertion, shared by every check below
361
+ // (Recipient binding, IdP-init replay dedup, AuthnStatement facts) —
362
+ // they all read the same signed bytes, so parsing once is both
363
+ // cheaper and impossible to accidentally diverge.
364
+ const parsed = parseVerifiedAssertion(profile)
365
+ if ("failure" in parsed) return parsed.failure
366
+ const assertionDoc = parsed.doc
367
+
368
+ // Gauntlet item 6 — node-saml does not enforce @Recipient; we do,
369
+ // against the exact ACS URL the IdP saw (committed for SP-init,
370
+ // derived for IdP-init — identical value either way).
371
+ const recipientFailure = checkRecipient(assertionDoc, acsUrl)
372
+ if (recipientFailure) return recipientFailure
373
+
374
+ // IdP-init replay: no InResponseTo single-use to lean on, so dedup
375
+ // the signed assertion's @ID. SP-init does not need this (the
376
+ // request id is already single-use).
377
+ if (idpInitiated) {
378
+ const replay = extractReplayInfo(assertionDoc)
379
+ if (!replay) {
380
+ return {
381
+ kind: "error",
382
+ error: authError.internalError(
383
+ "saml-sp: cannot read assertion @ID for IdP-initiated replay " +
384
+ "protection. Refusing to authenticate.",
385
+ ),
386
+ }
387
+ }
388
+ const key = `idp-replay:${replay.assertionId}`
389
+ const seen = await ctx.methodScratch.get(key)
390
+ if (seen.ok) {
391
+ return {
392
+ kind: "denied",
393
+ reason: "assertion replay detected (assertion ID already seen)",
394
+ }
395
+ }
396
+ const skewMs = (config.clockSkewSeconds ?? 60) * 1000
397
+ const now = Date.now()
398
+ const horizon =
399
+ replay.notOnOrAfterMs !== null
400
+ ? replay.notOnOrAfterMs - now + skewMs
401
+ : 10 * 60_000
402
+ // Clamp: never below the skew window, never an unbounded entry.
403
+ const ttlMs = Math.min(Math.max(horizon, skewMs, 60_000), 24 * 60 * 60_000)
404
+ const recorded = await ctx.methodScratch.put(key, "1", ttlMs)
405
+ if (!recorded.ok) {
406
+ // The dedup store is unavailable — failing open would allow
407
+ // replay. Fail closed.
408
+ return {
409
+ kind: "error",
410
+ error: authError.internalError(
411
+ "saml-sp: could not record assertion ID for replay protection",
412
+ ),
413
+ }
414
+ }
415
+ }
416
+
417
+ const authnStatement = extractAuthnStatement(assertionDoc)
418
+
419
+ const verified: VerifiedProfile = {
420
+ nameID: profile.nameID,
421
+ nameIDFormat: profile.nameIDFormat ?? "",
422
+ ...(profile.sessionIndex !== undefined
423
+ ? { sessionIndex: profile.sessionIndex }
424
+ : {}),
425
+ ...authnStatement,
426
+ attributes: profile.attributes ?? {},
427
+ responseXml: profile.getSamlResponseXml?.() ?? "",
428
+ }
429
+
430
+ const mapped = mapProfile(verified, config.attributeMapping)
431
+ if ("error" in mapped) {
432
+ return { kind: "denied", reason: mapped.error }
433
+ }
434
+
435
+ return {
436
+ kind: "success",
437
+ providerSubject: mapped.providerSubject,
438
+ properties: mapped.properties,
439
+ ...(idpInitiated && config.idpInitiated
440
+ ? {
441
+ unsolicitedBinding: {
442
+ clientId: config.idpInitiated.defaultClientId,
443
+ redirectUri: config.idpInitiated.defaultRedirectUri,
444
+ scopes: config.idpInitiated.defaultScopes ?? [],
445
+ },
446
+ }
447
+ : {}),
448
+ }
449
+ }