@_mustachio/openauth 0.10.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 (277) 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 +45 -0
  13. package/dist/esm/adapters/memory/token-store.js +13 -0
  14. package/dist/esm/adapters/postgres/executor.js +2 -2
  15. package/dist/esm/adapters/postgres/index.js +33 -10
  16. package/dist/esm/adapters/postgres/migrations.js +8 -2
  17. package/dist/esm/adapters/postgres/session-store.js +42 -0
  18. package/dist/esm/client.js +35 -28
  19. package/dist/esm/domain/authorize.js +7 -2
  20. package/dist/esm/domain/callback.js +124 -7
  21. package/dist/esm/domain/client-auth.js +2 -2
  22. package/dist/esm/domain/crypto.js +14 -14
  23. package/dist/esm/domain/discovery.js +46 -4
  24. package/dist/esm/domain/dpop.js +122 -0
  25. package/dist/esm/domain/id-token.js +94 -0
  26. package/dist/esm/domain/introspect.js +5 -1
  27. package/dist/esm/domain/jwt.js +30 -2
  28. package/dist/esm/domain/logout.js +75 -0
  29. package/dist/esm/domain/method-dispatch.js +27 -1
  30. package/dist/esm/domain/method-route.js +83 -1
  31. package/dist/esm/domain/par.js +51 -0
  32. package/dist/esm/domain/pkce.js +2 -2
  33. package/dist/esm/domain/refresh.js +12 -1
  34. package/dist/esm/domain/register.js +74 -0
  35. package/dist/esm/domain/revoke.js +2 -2
  36. package/dist/esm/domain/state-envelope.js +2 -2
  37. package/dist/esm/domain/token-exchange.js +1 -0
  38. package/dist/esm/domain/token.js +53 -12
  39. package/dist/esm/domain/userinfo.js +38 -3
  40. package/dist/esm/error.js +8 -8
  41. package/dist/esm/http/cookies.js +19 -2
  42. package/dist/esm/http/errors.js +4 -4
  43. package/dist/esm/http/handlers/authorize.js +92 -21
  44. package/dist/esm/http/handlers/callback.js +7 -1
  45. package/dist/esm/http/handlers/end-session.js +64 -0
  46. package/dist/esm/http/handlers/metadata.js +7 -4
  47. package/dist/esm/http/handlers/method-route.js +47 -2
  48. package/dist/esm/http/handlers/par.js +53 -0
  49. package/dist/esm/http/handlers/register.js +46 -0
  50. package/dist/esm/http/handlers/revocation.js +2 -2
  51. package/dist/esm/http/handlers/token.js +36 -6
  52. package/dist/esm/http/handlers/userinfo.js +35 -29
  53. package/dist/esm/http/middleware/tenant.js +6 -6
  54. package/dist/esm/http/router.js +10 -0
  55. package/dist/esm/http/schemas/authorize.js +8 -2
  56. package/dist/esm/http/schemas/end-session.js +13 -0
  57. package/dist/esm/http/schemas/par.js +9 -0
  58. package/dist/esm/http/schemas/register.js +16 -0
  59. package/dist/esm/http/schemas/revocation.js +2 -2
  60. package/dist/esm/http/schemas/token.js +7 -7
  61. package/dist/esm/index.js +40 -34
  62. package/dist/esm/methods/oauth2-factory.js +2 -2
  63. package/dist/esm/methods/password-hash.js +2 -2
  64. package/dist/esm/methods/providers/index.js +30 -14
  65. package/dist/esm/methods/saml-sp/acs.js +208 -0
  66. package/dist/esm/methods/saml-sp/attributes.js +75 -0
  67. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  68. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  69. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  70. package/dist/esm/methods/saml-sp/factory.js +70 -0
  71. package/dist/esm/methods/saml-sp/index.js +7 -0
  72. package/dist/esm/methods/saml-sp/metadata.js +69 -0
  73. package/dist/esm/methods/saml-sp/method.js +29 -0
  74. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  75. package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
  76. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  77. package/dist/esm/methods/saml-sp/sls.js +154 -0
  78. package/dist/esm/methods/saml-sp/types.js +0 -0
  79. package/dist/esm/types/error.js +5 -0
  80. package/dist/esm/types/result.js +3 -3
  81. package/dist/esm/ui/forms.js +3 -3
  82. package/dist/esm/ui/index.js +8 -5
  83. package/dist/esm/ui/picker.js +2 -2
  84. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  85. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  86. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  87. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  88. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  89. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  90. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  91. package/dist/types/adapters/memory/session-store.d.ts +6 -1
  92. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  93. package/dist/types/adapters/memory/token-store.d.ts +1 -0
  94. package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
  95. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  96. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  97. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  98. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  99. package/dist/types/client.d.ts +19 -0
  100. package/dist/types/client.d.ts.map +1 -1
  101. package/dist/types/domain/authorize.d.ts.map +1 -1
  102. package/dist/types/domain/callback.d.ts +16 -1
  103. package/dist/types/domain/callback.d.ts.map +1 -1
  104. package/dist/types/domain/client-credentials.d.ts +2 -0
  105. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  106. package/dist/types/domain/discovery.d.ts +37 -0
  107. package/dist/types/domain/discovery.d.ts.map +1 -1
  108. package/dist/types/domain/dpop.d.ts +61 -0
  109. package/dist/types/domain/dpop.d.ts.map +1 -0
  110. package/dist/types/domain/id-token.d.ts +83 -0
  111. package/dist/types/domain/id-token.d.ts.map +1 -0
  112. package/dist/types/domain/introspect.d.ts +8 -0
  113. package/dist/types/domain/introspect.d.ts.map +1 -1
  114. package/dist/types/domain/jwt.d.ts +27 -1
  115. package/dist/types/domain/jwt.d.ts.map +1 -1
  116. package/dist/types/domain/logout.d.ts +69 -0
  117. package/dist/types/domain/logout.d.ts.map +1 -0
  118. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  119. package/dist/types/domain/method-route.d.ts +57 -1
  120. package/dist/types/domain/method-route.d.ts.map +1 -1
  121. package/dist/types/domain/par.d.ts +62 -0
  122. package/dist/types/domain/par.d.ts.map +1 -0
  123. package/dist/types/domain/refresh.d.ts +9 -0
  124. package/dist/types/domain/refresh.d.ts.map +1 -1
  125. package/dist/types/domain/register.d.ts +33 -0
  126. package/dist/types/domain/register.d.ts.map +1 -0
  127. package/dist/types/domain/token-exchange.d.ts +2 -0
  128. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  129. package/dist/types/domain/token.d.ts +41 -1
  130. package/dist/types/domain/token.d.ts.map +1 -1
  131. package/dist/types/domain/userinfo.d.ts +44 -5
  132. package/dist/types/domain/userinfo.d.ts.map +1 -1
  133. package/dist/types/http/context.d.ts +6 -1
  134. package/dist/types/http/context.d.ts.map +1 -1
  135. package/dist/types/http/cookies.d.ts +9 -1
  136. package/dist/types/http/cookies.d.ts.map +1 -1
  137. package/dist/types/http/handlers/authorize.d.ts.map +1 -1
  138. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  139. package/dist/types/http/handlers/end-session.d.ts +3 -0
  140. package/dist/types/http/handlers/end-session.d.ts.map +1 -0
  141. package/dist/types/http/handlers/metadata.d.ts +1 -1
  142. package/dist/types/http/handlers/metadata.d.ts.map +1 -1
  143. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  144. package/dist/types/http/handlers/par.d.ts +3 -0
  145. package/dist/types/http/handlers/par.d.ts.map +1 -0
  146. package/dist/types/http/handlers/register.d.ts +3 -0
  147. package/dist/types/http/handlers/register.d.ts.map +1 -0
  148. package/dist/types/http/handlers/token.d.ts.map +1 -1
  149. package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
  150. package/dist/types/http/router.d.ts.map +1 -1
  151. package/dist/types/http/schemas/authorize.d.ts +40 -0
  152. package/dist/types/http/schemas/authorize.d.ts.map +1 -1
  153. package/dist/types/http/schemas/end-session.d.ts +31 -0
  154. package/dist/types/http/schemas/end-session.d.ts.map +1 -0
  155. package/dist/types/http/schemas/par.d.ts +20 -0
  156. package/dist/types/http/schemas/par.d.ts.map +1 -0
  157. package/dist/types/http/schemas/register.d.ts +42 -0
  158. package/dist/types/http/schemas/register.d.ts.map +1 -0
  159. package/dist/types/index.d.ts +3 -3
  160. package/dist/types/index.d.ts.map +1 -1
  161. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  162. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  163. package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
  164. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  165. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  166. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  167. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  168. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  169. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  170. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  171. package/dist/types/methods/saml-sp/factory.d.ts +704 -0
  172. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  173. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  174. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  175. package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
  176. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  177. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  178. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  179. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  180. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  181. package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
  182. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  183. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  184. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  185. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  186. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  187. package/dist/types/methods/saml-sp/types.d.ts +204 -0
  188. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  189. package/dist/types/ports/audit-log.d.ts +40 -0
  190. package/dist/types/ports/audit-log.d.ts.map +1 -1
  191. package/dist/types/ports/session-store.d.ts +50 -0
  192. package/dist/types/ports/session-store.d.ts.map +1 -1
  193. package/dist/types/ports/token-store.d.ts +12 -0
  194. package/dist/types/ports/token-store.d.ts.map +1 -1
  195. package/dist/types/types/authorization.d.ts +23 -0
  196. package/dist/types/types/authorization.d.ts.map +1 -1
  197. package/dist/types/types/error.d.ts +9 -0
  198. package/dist/types/types/error.d.ts.map +1 -1
  199. package/dist/types/types/flow.d.ts +10 -0
  200. package/dist/types/types/flow.d.ts.map +1 -1
  201. package/dist/types/types/idp.d.ts +162 -1
  202. package/dist/types/types/idp.d.ts.map +1 -1
  203. package/dist/types/types/method.d.ts +109 -0
  204. package/dist/types/types/method.d.ts.map +1 -1
  205. package/dist/types/types/tenant.d.ts +37 -0
  206. package/dist/types/types/tenant.d.ts.map +1 -1
  207. package/dist/types/types/token.d.ts +128 -0
  208. package/dist/types/types/token.d.ts.map +1 -1
  209. package/package.json +9 -1
  210. package/src/adapters/d1/migrations.ts +5 -0
  211. package/src/adapters/d1/session-store.ts +68 -0
  212. package/src/adapters/durable-object/session-store.ts +61 -0
  213. package/src/adapters/dynamo/session-store.ts +63 -0
  214. package/src/adapters/memory/session-store.ts +83 -1
  215. package/src/adapters/memory/token-store.ts +16 -0
  216. package/src/adapters/postgres/migrations.ts +6 -0
  217. package/src/adapters/postgres/session-store.ts +69 -0
  218. package/src/client.ts +32 -0
  219. package/src/domain/authorize.ts +9 -0
  220. package/src/domain/callback.ts +220 -7
  221. package/src/domain/client-credentials.ts +2 -0
  222. package/src/domain/discovery.ts +80 -2
  223. package/src/domain/dpop.ts +293 -0
  224. package/src/domain/id-token.ts +213 -0
  225. package/src/domain/introspect.ts +10 -0
  226. package/src/domain/jwt.ts +80 -1
  227. package/src/domain/logout.ts +180 -0
  228. package/src/domain/method-dispatch.ts +53 -1
  229. package/src/domain/method-route.ts +195 -1
  230. package/src/domain/par.ts +141 -0
  231. package/src/domain/refresh.ts +41 -0
  232. package/src/domain/register.ts +168 -0
  233. package/src/domain/token-exchange.ts +11 -0
  234. package/src/domain/token.ts +134 -8
  235. package/src/domain/userinfo.ts +121 -6
  236. package/src/http/context.ts +10 -0
  237. package/src/http/cookies.ts +19 -1
  238. package/src/http/handlers/authorize.ts +146 -24
  239. package/src/http/handlers/callback.ts +9 -1
  240. package/src/http/handlers/end-session.ts +106 -0
  241. package/src/http/handlers/metadata.ts +7 -2
  242. package/src/http/handlers/method-route.ts +75 -2
  243. package/src/http/handlers/par.ts +84 -0
  244. package/src/http/handlers/register.ts +70 -0
  245. package/src/http/handlers/token.ts +56 -0
  246. package/src/http/handlers/userinfo.ts +62 -38
  247. package/src/http/router.ts +19 -0
  248. package/src/http/schemas/authorize.ts +27 -0
  249. package/src/http/schemas/end-session.ts +23 -0
  250. package/src/http/schemas/par.ts +23 -0
  251. package/src/http/schemas/register.ts +29 -0
  252. package/src/index.ts +21 -1
  253. package/src/methods/saml-sp/acs.ts +399 -0
  254. package/src/methods/saml-sp/attributes.ts +137 -0
  255. package/src/methods/saml-sp/authnrequest.ts +128 -0
  256. package/src/methods/saml-sp/cache-provider.ts +76 -0
  257. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  258. package/src/methods/saml-sp/factory.ts +118 -0
  259. package/src/methods/saml-sp/index.ts +34 -0
  260. package/src/methods/saml-sp/metadata.ts +204 -0
  261. package/src/methods/saml-sp/method.ts +65 -0
  262. package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
  263. package/src/methods/saml-sp/saml-instance.ts +173 -0
  264. package/src/methods/saml-sp/slo-initiate.ts +177 -0
  265. package/src/methods/saml-sp/sls.ts +306 -0
  266. package/src/methods/saml-sp/types.ts +203 -0
  267. package/src/ports/CONSISTENCY.md +77 -21
  268. package/src/ports/audit-log.ts +42 -0
  269. package/src/ports/session-store.ts +61 -0
  270. package/src/ports/token-store.ts +13 -0
  271. package/src/types/authorization.ts +25 -0
  272. package/src/types/error.ts +23 -0
  273. package/src/types/flow.ts +10 -0
  274. package/src/types/idp.ts +170 -0
  275. package/src/types/method.ts +110 -0
  276. package/src/types/tenant.ts +37 -0
  277. package/src/types/token.ts +135 -0
@@ -0,0 +1,399 @@
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, deriveSpEntityId } 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 checkRecipient(
85
+ profile: NodeSamlProfile,
86
+ acsUrl: string,
87
+ ): MethodResult<SamlSpProperties, SamlSpState> | null {
88
+ if (typeof profile.getAssertionXml !== "function") {
89
+ // node-saml v5.1 populates this on a successful verify. Its
90
+ // absence means the library contract changed under us — fail loud
91
+ // rather than skip a security check we now promise.
92
+ return {
93
+ kind: "error",
94
+ error: authError.internalError(
95
+ "saml-sp: node-saml profile exposes no getAssertionXml(); cannot " +
96
+ "perform the Recipient binding check. Refusing to authenticate.",
97
+ ),
98
+ }
99
+ }
100
+
101
+ let doc: Document
102
+ try {
103
+ doc = new DOMParser().parseFromString(
104
+ profile.getAssertionXml(),
105
+ "text/xml",
106
+ ) as unknown as Document
107
+ } catch (e) {
108
+ return {
109
+ kind: "error",
110
+ error: authError.internalError(
111
+ "saml-sp: failed to parse the verified assertion for the " +
112
+ "Recipient check",
113
+ e,
114
+ ),
115
+ }
116
+ }
117
+
118
+ // Namespace-agnostic: match by local name regardless of prefix.
119
+ const nodes = doc.getElementsByTagNameNS(
120
+ "*",
121
+ "SubjectConfirmationData",
122
+ )
123
+ const recipients: string[] = []
124
+ for (let i = 0; i < nodes.length; i++) {
125
+ const r = nodes[i]?.getAttribute("Recipient")
126
+ if (r) recipients.push(r)
127
+ }
128
+
129
+ if (recipients.length === 0) {
130
+ return {
131
+ kind: "denied",
132
+ reason:
133
+ "assertion has no SubjectConfirmationData/@Recipient binding it " +
134
+ "to this ACS",
135
+ }
136
+ }
137
+ if (!recipients.includes(acsUrl)) {
138
+ return {
139
+ kind: "denied",
140
+ reason: "recipient mismatch: assertion not addressed to this ACS",
141
+ }
142
+ }
143
+ return null
144
+ }
145
+
146
+ /**
147
+ * For IdP-initiated replay dedup: pull the assertion `@ID` and the
148
+ * tightest `NotOnOrAfter` from the **verified** assertion XML (the same
149
+ * bytes `checkRecipient` reads). Returns `null` if the assertion XML is
150
+ * unavailable (caller treats that as a fail-loud error — we will not
151
+ * skip replay protection we promised).
152
+ */
153
+ function extractReplayInfo(
154
+ profile: NodeSamlProfile,
155
+ ): { assertionId: string; notOnOrAfterMs: number | null } | null {
156
+ if (typeof profile.getAssertionXml !== "function") return null
157
+ let doc: Document
158
+ try {
159
+ doc = new DOMParser().parseFromString(
160
+ profile.getAssertionXml(),
161
+ "text/xml",
162
+ ) as unknown as Document
163
+ } catch {
164
+ return null
165
+ }
166
+ const root = doc.documentElement
167
+ const assertionId = root?.getAttribute("ID") ?? ""
168
+ if (!assertionId) return null
169
+ let earliest: number | null = null
170
+ for (const tag of ["Conditions", "SubjectConfirmationData"]) {
171
+ const els = doc.getElementsByTagNameNS("*", tag)
172
+ for (let i = 0; i < els.length; i++) {
173
+ const v = els[i]?.getAttribute("NotOnOrAfter")
174
+ if (!v) continue
175
+ const ms = Date.parse(v)
176
+ if (!Number.isNaN(ms)) earliest = earliest === null ? ms : Math.min(earliest, ms)
177
+ }
178
+ }
179
+ return { assertionId, notOnOrAfterMs: earliest }
180
+ }
181
+
182
+ export async function consumeAssertion(
183
+ ctx: MethodContext<SamlSpState>,
184
+ methodId: string,
185
+ config: SamlSpConfig,
186
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
187
+ // Discriminator: a consumed flow ⇒ SP-initiated; no flow ⇒ an
188
+ // unsolicited IdP-initiated POST (the framework only routes one here
189
+ // when this instance opted in via `unsolicitedCallback`).
190
+ const idpInitiated = ctx.flow === null
191
+
192
+ let spEntityId: string
193
+ let acsUrl: string
194
+ if (idpInitiated) {
195
+ if (!config.idpInitiated) {
196
+ // Fail-closed: should be unreachable (the framework gates on
197
+ // `unsolicitedCallback`, set only when idpInitiated is config'd).
198
+ return {
199
+ kind: "error",
200
+ error: authError.internalError(
201
+ "saml-sp: unsolicited Response reached a method with no " +
202
+ "idpInitiated config",
203
+ ),
204
+ }
205
+ }
206
+ if (!ctx.dispatch) {
207
+ return {
208
+ kind: "error",
209
+ error: authError.internalError(
210
+ "saml-sp: IdP-initiated ACS dispatched without issuer context",
211
+ ),
212
+ }
213
+ }
214
+ // Same derivation as AuthnRequest / metadata — no drift.
215
+ spEntityId = deriveSpEntityId(
216
+ ctx.dispatch.issuerUrl,
217
+ ctx.tenant.id,
218
+ methodId,
219
+ )
220
+ acsUrl = ctx.dispatch.callbackUrl
221
+ } else {
222
+ const state = ctx.methodState
223
+ if (!state || !state.spEntityId || !state.acsUrl) {
224
+ return {
225
+ kind: "error",
226
+ error: authError.internalError(
227
+ "saml-sp: ACS reached without AuthnRequest method state " +
228
+ "(spEntityId / acsUrl). The flow did not originate from this method.",
229
+ ),
230
+ }
231
+ }
232
+ spEntityId = state.spEntityId
233
+ acsUrl = state.acsUrl
234
+ }
235
+
236
+ let samlResponse: string | null
237
+ let relayState: string | null
238
+ try {
239
+ const form = new URLSearchParams(await ctx.request.text())
240
+ samlResponse = form.get("SAMLResponse")
241
+ relayState = form.get("RelayState")
242
+ } catch {
243
+ return {
244
+ kind: "error",
245
+ error: authError.internalError("saml-sp: ACS could not read POST body"),
246
+ }
247
+ }
248
+ if (!samlResponse) {
249
+ return { kind: "denied", reason: "missing SAMLResponse" }
250
+ }
251
+
252
+ // Build is a configuration concern (e.g. no signing cert within its
253
+ // validity window, or a rotation gap). A throw here is an operator
254
+ // fault, NOT a user auth failure — surfacing it as `denied` would
255
+ // hide a misconfiguration behind per-user "access denied" noise.
256
+ let saml: ReturnType<typeof buildSamlInstance>
257
+ try {
258
+ saml = buildSamlInstance(
259
+ config,
260
+ {
261
+ spEntityId,
262
+ acsUrl,
263
+ scratch: ctx.methodScratch,
264
+ ...(idpInitiated ? { idpInitiated: true } : {}),
265
+ // Encrypted assertions are opt-in per connection. Absent ⇒ no
266
+ // decryptionPvk ⇒ node-saml rejects an EncryptedAssertion.
267
+ ...(config.allowEncryptedAssertions && config.decryptionKey
268
+ ? { decryptionPvk: config.decryptionKey.privateKeyPem }
269
+ : {}),
270
+ },
271
+ Date.now(),
272
+ )
273
+ } catch (e) {
274
+ return {
275
+ kind: "error",
276
+ error: authError.internalError(
277
+ `saml-sp: cannot construct verifier: ${
278
+ e instanceof Error ? e.message : String(e)
279
+ }`,
280
+ e,
281
+ ),
282
+ }
283
+ }
284
+
285
+ let profile: NodeSamlProfile | null
286
+ try {
287
+ const result = await saml.validatePostResponseAsync({
288
+ SAMLResponse: samlResponse,
289
+ ...(relayState !== null ? { RelayState: relayState } : {}),
290
+ })
291
+ profile = result.profile as NodeSamlProfile | null
292
+ } catch (e) {
293
+ // Every node-saml verification failure (bad/absent signature,
294
+ // signature-wrapping, issuer/audience mismatch, expired
295
+ // conditions, unknown InResponseTo) throws here. These are
296
+ // controlled auth failures, not server faults.
297
+ const msg = e instanceof Error ? e.message : String(e)
298
+ // node-saml throws this exact message when an EncryptedAssertion
299
+ // arrives but no decryptionPvk is configured. Give the operator a
300
+ // signal that the *connection* is mis/under-configured rather than
301
+ // a generic "assertion rejected".
302
+ if (
303
+ !config.allowEncryptedAssertions &&
304
+ msg.includes("No decryption key")
305
+ ) {
306
+ return {
307
+ kind: "denied",
308
+ reason:
309
+ "encrypted assertion received but encrypted assertions are not " +
310
+ "enabled for this SAML connection (set allowEncryptedAssertions " +
311
+ "+ decryptionKey)",
312
+ }
313
+ }
314
+ return { kind: "denied", reason: `assertion rejected: ${msg}` }
315
+ }
316
+
317
+ if (!profile || !profile.nameID) {
318
+ return { kind: "denied", reason: "assertion produced no usable subject" }
319
+ }
320
+
321
+ // Gauntlet item 6 — node-saml does not enforce @Recipient; we do,
322
+ // against the exact ACS URL the IdP saw (committed for SP-init,
323
+ // derived for IdP-init — identical value either way).
324
+ const recipientFailure = checkRecipient(profile, acsUrl)
325
+ if (recipientFailure) return recipientFailure
326
+
327
+ // IdP-init replay: no InResponseTo single-use to lean on, so dedup
328
+ // the signed assertion's @ID. SP-init does not need this (the
329
+ // request id is already single-use).
330
+ if (idpInitiated) {
331
+ const replay = extractReplayInfo(profile)
332
+ if (!replay) {
333
+ return {
334
+ kind: "error",
335
+ error: authError.internalError(
336
+ "saml-sp: cannot read assertion @ID for IdP-initiated replay " +
337
+ "protection. Refusing to authenticate.",
338
+ ),
339
+ }
340
+ }
341
+ const key = `idp-replay:${replay.assertionId}`
342
+ const seen = await ctx.methodScratch.get(key)
343
+ if (seen.ok) {
344
+ return {
345
+ kind: "denied",
346
+ reason: "assertion replay detected (assertion ID already seen)",
347
+ }
348
+ }
349
+ const skewMs = (config.clockSkewSeconds ?? 60) * 1000
350
+ const now = Date.now()
351
+ const horizon =
352
+ replay.notOnOrAfterMs !== null
353
+ ? replay.notOnOrAfterMs - now + skewMs
354
+ : 10 * 60_000
355
+ // Clamp: never below the skew window, never an unbounded entry.
356
+ const ttlMs = Math.min(Math.max(horizon, skewMs, 60_000), 24 * 60 * 60_000)
357
+ const recorded = await ctx.methodScratch.put(key, "1", ttlMs)
358
+ if (!recorded.ok) {
359
+ // The dedup store is unavailable — failing open would allow
360
+ // replay. Fail closed.
361
+ return {
362
+ kind: "error",
363
+ error: authError.internalError(
364
+ "saml-sp: could not record assertion ID for replay protection",
365
+ ),
366
+ }
367
+ }
368
+ }
369
+
370
+ const verified: VerifiedProfile = {
371
+ nameID: profile.nameID,
372
+ nameIDFormat: profile.nameIDFormat ?? "",
373
+ ...(profile.sessionIndex !== undefined
374
+ ? { sessionIndex: profile.sessionIndex }
375
+ : {}),
376
+ attributes: profile.attributes ?? {},
377
+ responseXml: profile.getSamlResponseXml?.() ?? "",
378
+ }
379
+
380
+ const mapped = mapProfile(verified, config.attributeMapping)
381
+ if ("error" in mapped) {
382
+ return { kind: "denied", reason: mapped.error }
383
+ }
384
+
385
+ return {
386
+ kind: "success",
387
+ providerSubject: mapped.providerSubject,
388
+ properties: mapped.properties,
389
+ ...(idpInitiated && config.idpInitiated
390
+ ? {
391
+ unsolicitedBinding: {
392
+ clientId: config.idpInitiated.defaultClientId,
393
+ redirectUri: config.idpInitiated.defaultRedirectUri,
394
+ scopes: config.idpInitiated.defaultScopes ?? [],
395
+ },
396
+ }
397
+ : {}),
398
+ }
399
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Attribute mapping — translate a node-saml `Profile` (already
3
+ * cryptographically verified by the time it reaches here) into our
4
+ * `SamlSpProperties`.
5
+ *
6
+ * node-saml flattens the assertion's `AttributeStatement` onto the
7
+ * `Profile` object as arbitrary top-level keys (single value → string,
8
+ * multi-valued → array), plus the structural `nameID` /
9
+ * `nameIDFormat` / `sessionIndex`. The host's `success` callback owns
10
+ * the final `SubjectClaim`; this layer just normalises the SAML side
11
+ * per the tenant's configured `SamlAttributeMapping`.
12
+ *
13
+ * Pure: no node-saml import, no I/O. `Profile` is modelled here as the
14
+ * minimal structural shape we read, so this file stays free of the
15
+ * third-party type (the public-API leak guard depends on that).
16
+ */
17
+ import type {
18
+ SamlAttributeMapping,
19
+ SamlAttributeRef,
20
+ SamlNameIdFormat,
21
+ SamlSpProperties,
22
+ } from "./types"
23
+
24
+ /** The slice of node-saml's `Profile` we actually consume. */
25
+ export type VerifiedProfile = {
26
+ nameID: string
27
+ nameIDFormat: string
28
+ sessionIndex?: string
29
+ attributes: Record<string, unknown>
30
+ authnInstant?: string
31
+ responseXml: string
32
+ }
33
+
34
+ const NAME_ID_FORMAT_BY_URN: Record<string, SamlNameIdFormat> = {
35
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent": "persistent",
36
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient": "transient",
37
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress": "emailAddress",
38
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified": "unspecified",
39
+ }
40
+
41
+ function normalizeNameIdFormat(urn: string): SamlNameIdFormat {
42
+ return NAME_ID_FORMAT_BY_URN[urn] ?? "unspecified"
43
+ }
44
+
45
+ /** First value if array, the value if scalar, else undefined. */
46
+ function scalar(v: unknown): string | undefined {
47
+ if (typeof v === "string") return v
48
+ if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string") {
49
+ return v[0]
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ /** Preserve array shape for multi-valued attributes (e.g. groups). */
55
+ function multi(v: unknown): string | string[] | undefined {
56
+ if (typeof v === "string") return v
57
+ if (Array.isArray(v)) {
58
+ const strs = v.filter((x): x is string => typeof x === "string")
59
+ return strs.length > 0 ? strs : undefined
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ function resolveRef(
65
+ ref: SamlAttributeRef,
66
+ profile: VerifiedProfile,
67
+ ): string | string[] | undefined {
68
+ if (ref.source === "nameId") return profile.nameID
69
+ return multi(profile.attributes[ref.name])
70
+ }
71
+
72
+ export type MappedSubject = {
73
+ providerSubject: string
74
+ properties: SamlSpProperties
75
+ }
76
+
77
+ /**
78
+ * Build `(providerSubject, properties)` from a verified profile.
79
+ * Returns a string describing the failure when the configured subject
80
+ * source resolves to nothing — the caller turns that into a `denied`
81
+ * result rather than minting a subject-less success.
82
+ */
83
+ export function mapProfile(
84
+ profile: VerifiedProfile,
85
+ mapping: SamlAttributeMapping,
86
+ ): MappedSubject | { error: string } {
87
+ const subjectRef: SamlAttributeRef = mapping.subject ?? { source: "nameId" }
88
+ const subjectVal = scalar(resolveRef(subjectRef, profile))
89
+ if (!subjectVal) {
90
+ return {
91
+ error:
92
+ subjectRef.source === "nameId"
93
+ ? "assertion NameID is empty; cannot derive subject"
94
+ : `subject attribute "${subjectRef.name}" missing from assertion`,
95
+ }
96
+ }
97
+
98
+ const attributes: Record<string, string | string[]> = {}
99
+
100
+ const put = (key: string, ref: SamlAttributeRef | undefined): void => {
101
+ if (!ref) return
102
+ const v = resolveRef(ref, profile)
103
+ if (v !== undefined) attributes[key] = v
104
+ }
105
+
106
+ put("email", mapping.email)
107
+ put("name", mapping.name)
108
+ put("groups", mapping.groups)
109
+ if (mapping.emailVerified) {
110
+ attributes["emailVerified"] = mapping.emailVerified.value
111
+ ? "true"
112
+ : "false"
113
+ }
114
+ for (const [key, ref] of Object.entries(mapping.custom ?? {})) {
115
+ put(key, ref)
116
+ }
117
+
118
+ const authnInstant = profile.authnInstant
119
+ ? Date.parse(profile.authnInstant)
120
+ : Date.now()
121
+
122
+ return {
123
+ providerSubject: subjectVal,
124
+ properties: {
125
+ nameId: {
126
+ value: profile.nameID,
127
+ format: normalizeNameIdFormat(profile.nameIDFormat),
128
+ },
129
+ attributes,
130
+ ...(profile.sessionIndex !== undefined
131
+ ? { sessionIndex: profile.sessionIndex }
132
+ : {}),
133
+ authnInstant: Number.isNaN(authnInstant) ? Date.now() : authnInstant,
134
+ raw: { responseXml: profile.responseXml },
135
+ },
136
+ }
137
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `GET /authorize` — SP-initiated SSO. Builds a SAML `AuthnRequest`
3
+ * via node-saml and redirects the user agent to the IdP's SSO
4
+ * endpoint (HTTP-Redirect binding).
5
+ *
6
+ * The framework state envelope (`ctx.dispatch.state`) is carried as
7
+ * RelayState; the IdP echoes it back on the ACS POST so the standard
8
+ * callback machinery can recover the tenant + flow. node-saml records
9
+ * the generated request id in the `methodScratch`-backed cache for
10
+ * `InResponseTo` enforcement at the ACS.
11
+ */
12
+ import { authError } from "../../types/error"
13
+ import type { MethodContext, MethodResult } from "../../types/method"
14
+ import { isErr } from "../../types/result"
15
+
16
+ import { buildSamlInstance, deriveSpEntityId } from "./saml-instance"
17
+ import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
18
+
19
+ export async function buildAuthnRequestRedirect(
20
+ ctx: MethodContext<SamlSpState>,
21
+ methodId: string,
22
+ config: SamlSpConfig,
23
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
24
+ if (!ctx.dispatch) {
25
+ return {
26
+ kind: "error",
27
+ error: authError.internalError("saml-sp: dispatch missing on /authorize"),
28
+ }
29
+ }
30
+
31
+ // O3: signed AuthnRequest uses a per-connection SP keypair from
32
+ // config (decoupled from the OIDC KeyStore). The Zod schema already
33
+ // enforces `signAuthnRequest ⇒ signingKey`; guard defensively so a
34
+ // schema regression can never silently emit an unsigned request when
35
+ // the operator asked for a signed one.
36
+ if (config.signAuthnRequest && !config.signingKey) {
37
+ return {
38
+ kind: "error",
39
+ error: authError.internalError(
40
+ "saml-sp: signAuthnRequest is true but signingKey is missing " +
41
+ "(config schema should have rejected this).",
42
+ ),
43
+ }
44
+ }
45
+
46
+ const spEntityId = deriveSpEntityId(
47
+ ctx.dispatch.issuerUrl,
48
+ ctx.tenant.id,
49
+ methodId,
50
+ )
51
+
52
+ const acsUrl = ctx.dispatch.callbackUrl
53
+
54
+ // Fail fast if the SessionStore adapter doesn't implement the
55
+ // scratch trio. node-saml ignores its CacheProvider.saveAsync
56
+ // return value, so without this probe we would issue an
57
+ // AuthnRequest whose request id is never cached — every assertion
58
+ // would then be rejected at the ACS with an opaque
59
+ // "InResponseTo not valid", and the operator would have no signal
60
+ // that the real problem is an unsupported adapter. Mirrors the
61
+ // explicit signAuthnRequest guard above.
62
+ const probe = await ctx.methodScratch.put(
63
+ "authnrequest-scratch-probe",
64
+ "1",
65
+ 1000,
66
+ )
67
+ if (isErr(probe)) {
68
+ return {
69
+ kind: "error",
70
+ error: authError.internalError(
71
+ "saml-sp: the configured SessionStore adapter does not support " +
72
+ "methodScratch (saveScratch/readScratch/deleteScratch), which " +
73
+ "SAML SP requires for InResponseTo replay protection. Deploy " +
74
+ "SAML against an adapter that implements the scratch trio.",
75
+ ),
76
+ }
77
+ }
78
+
79
+ let redirectUrl: string
80
+ try {
81
+ const saml = buildSamlInstance(
82
+ config,
83
+ {
84
+ spEntityId,
85
+ acsUrl,
86
+ scratch: ctx.methodScratch,
87
+ ...(config.signAuthnRequest && config.signingKey
88
+ ? {
89
+ signing: {
90
+ privateKeyPem: config.signingKey.privateKeyPem,
91
+ certPem: config.signingKey.certPem,
92
+ },
93
+ }
94
+ : {}),
95
+ },
96
+ Date.now(),
97
+ )
98
+ redirectUrl = await saml.getAuthorizeUrlAsync(
99
+ ctx.dispatch.state,
100
+ undefined,
101
+ {},
102
+ )
103
+ } catch (e) {
104
+ return {
105
+ kind: "error",
106
+ error: authError.internalError(
107
+ `saml-sp: failed to build AuthnRequest: ${
108
+ e instanceof Error ? e.message : String(e)
109
+ }`,
110
+ e,
111
+ ),
112
+ }
113
+ }
114
+
115
+ return {
116
+ kind: "challenge",
117
+ response: new Response(null, {
118
+ status: 302,
119
+ headers: { location: redirectUrl },
120
+ }),
121
+ saveMethodState: {
122
+ relayState: ctx.dispatch.state,
123
+ issuedAt: Date.now(),
124
+ spEntityId,
125
+ acsUrl,
126
+ },
127
+ }
128
+ }