@_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,215 @@
1
+ /**
2
+ * SP metadata XML — the outbound half of metadata exchange
3
+ * (`parseSamlIdpMetadata` is the inbound half).
4
+ *
5
+ * Served anonymously at `GET /m/<methodId>/metadata` via the method's
6
+ * `publicRoutes` allowlist (no flow cookie). An enterprise IdP admin
7
+ * imports this document to register us as an SP.
8
+ *
9
+ * **Conformance invariant:** the `entityID` and ACS `Location` are
10
+ * derived from the *same* inputs the live AuthnRequest / ACS path uses
11
+ * (`resolveSpEntityId(config, issuerUrl, tenantId, methodId)` and
12
+ * `ctx.dispatch.callbackUrl`). The metadata therefore describes exactly
13
+ * what the runtime accepts — never an independently re-specified value
14
+ * that could drift. `metadata.test.ts` asserts this equality against
15
+ * `buildAuthnRequestRedirect` so drift fails CI.
16
+ *
17
+ * `KeyDescriptor` is emitted only when we actually sign AuthnRequests
18
+ * (advertising a cert we cannot use would be the bug);
19
+ * `SingleLogoutService` only when an IdP SLO endpoint is configured
20
+ * and the `/sls` route is therefore served (advertising an endpoint we
21
+ * do not serve would break interop). `AuthnRequestsSigned` and
22
+ * `WantAssertionsSigned` are both read from config, so they state
23
+ * actual runtime behaviour rather than a hardcoded assumption.
24
+ */
25
+ import { authError } from "../../types/error"
26
+ import type { MethodContext, MethodResult } from "../../types/method"
27
+
28
+ import { resolveSpEntityId } from "./saml-instance"
29
+ import type {
30
+ SamlNameIdFormat,
31
+ SamlSpConfig,
32
+ SamlSpProperties,
33
+ SamlSpState,
34
+ } from "./types"
35
+
36
+ const NAME_ID_FORMAT_URN: Record<SamlNameIdFormat, string> = {
37
+ persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
38
+ transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
39
+ emailAddress: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
40
+ unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
41
+ }
42
+
43
+ const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
44
+ const HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
45
+
46
+ /** Minimal XML attribute/text escaping for URL-shaped values. */
47
+ function xmlEscape(s: string): string {
48
+ return s
49
+ .replace(/&/g, "&amp;")
50
+ .replace(/</g, "&lt;")
51
+ .replace(/>/g, "&gt;")
52
+ .replace(/"/g, "&quot;")
53
+ }
54
+
55
+ export type SpMetadataInput = {
56
+ spEntityId: string
57
+ acsUrl: string
58
+ /**
59
+ * The SP's own Single Logout Service URL (`/m/<methodId>/sls`).
60
+ * Present ⇒ front-channel SLO is served, so the metadata advertises
61
+ * `SingleLogoutService` for both bindings we accept. Same
62
+ * advertise-only-what-we-serve invariant as the ACS / signing cert.
63
+ */
64
+ slsUrl?: string
65
+ nameIdFormat?: SamlNameIdFormat
66
+ /**
67
+ * SP signing cert (PEM). Present ⇒ we sign AuthnRequests, so the
68
+ * metadata advertises `AuthnRequestsSigned="true"` and a
69
+ * `KeyDescriptor use="signing"`. Keeping this in lockstep with the
70
+ * runtime (the same `config.signingKey.certPem` that actually signs)
71
+ * is the same anti-drift invariant as the entityID/ACS.
72
+ */
73
+ signingCertPem?: string
74
+ /**
75
+ * SP encryption cert (PEM). Present ⇒ the connection accepts
76
+ * encrypted assertions, so the metadata advertises a
77
+ * `KeyDescriptor use="encryption"` (the cert the IdP encrypts to —
78
+ * `config.decryptionKey.certPem`). Same anti-drift / advertise-only-
79
+ * what-we-serve invariant as the signing cert.
80
+ */
81
+ encryptionCertPem?: string
82
+ /**
83
+ * Whether we actually require a signed `<saml:Assertion>`
84
+ * (`config.requireSignedAssertion`, default `true`). Published as
85
+ * `WantAssertionsSigned` — same advertise-only-what-we-serve
86
+ * invariant as the certs: a connection relying on a signed
87
+ * `<Response>` instead must not claim otherwise in its metadata.
88
+ */
89
+ wantAssertionsSigned?: boolean
90
+ }
91
+
92
+ /** PEM cert body → bare base64 (SAML metadata X509Certificate form). */
93
+ function certBody(pem: string): string {
94
+ return pem
95
+ .replace(/-----BEGIN CERTIFICATE-----/g, "")
96
+ .replace(/-----END CERTIFICATE-----/g, "")
97
+ .replace(/\s+/g, "")
98
+ }
99
+
100
+ /**
101
+ * Pure SP metadata builder. Mirrors `parseSamlIdpMetadata`'s purity so
102
+ * it is trivially unit-testable and reusable.
103
+ */
104
+ export function buildSpMetadataXml(input: SpMetadataInput): string {
105
+ const entityId = xmlEscape(input.spEntityId)
106
+ const acs = xmlEscape(input.acsUrl)
107
+ const signed = input.signingCertPem !== undefined
108
+ const wantAssertionsSigned = input.wantAssertionsSigned ?? true
109
+ const nameIdLine =
110
+ input.nameIdFormat !== undefined
111
+ ? `\n <md:NameIDFormat>${NAME_ID_FORMAT_URN[input.nameIdFormat]}</md:NameIDFormat>`
112
+ : ""
113
+ const x509 = (cert: string) =>
114
+ `<ds:KeyInfo><ds:X509Data><ds:X509Certificate>` +
115
+ certBody(cert) +
116
+ `</ds:X509Certificate></ds:X509Data></ds:KeyInfo>`
117
+ const keyDescriptor =
118
+ (signed
119
+ ? `\n <md:KeyDescriptor use="signing">` +
120
+ x509(input.signingCertPem as string) +
121
+ `</md:KeyDescriptor>`
122
+ : "") +
123
+ (input.encryptionCertPem !== undefined
124
+ ? `\n <md:KeyDescriptor use="encryption">` +
125
+ x509(input.encryptionCertPem) +
126
+ `</md:KeyDescriptor>`
127
+ : "")
128
+ // Schema order: KeyDescriptor → SingleLogoutService → NameIDFormat →
129
+ // AssertionConsumerService. Advertise both bindings we accept at /sls.
130
+ const sls =
131
+ input.slsUrl !== undefined
132
+ ? `\n <md:SingleLogoutService ` +
133
+ `Binding="${HTTP_REDIRECT}" Location="${xmlEscape(input.slsUrl)}"/>` +
134
+ `\n <md:SingleLogoutService ` +
135
+ `Binding="${HTTP_POST}" Location="${xmlEscape(input.slsUrl)}"/>`
136
+ : ""
137
+
138
+ return (
139
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
140
+ `<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ` +
141
+ `xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ` +
142
+ `entityID="${entityId}">\n` +
143
+ ` <md:SPSSODescriptor AuthnRequestsSigned="${signed}" ` +
144
+ `WantAssertionsSigned="${wantAssertionsSigned}" ` +
145
+ `protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">` +
146
+ keyDescriptor +
147
+ sls +
148
+ nameIdLine +
149
+ `\n <md:AssertionConsumerService ` +
150
+ `Binding="${HTTP_POST}" Location="${acs}" index="0" isDefault="true"/>` +
151
+ `\n </md:SPSSODescriptor>\n` +
152
+ `</md:EntityDescriptor>\n`
153
+ )
154
+ }
155
+
156
+ /**
157
+ * `GET /metadata` route handler. Anonymous (declared in the method's
158
+ * `publicRoutes`): `ctx.flow` / `ctx.methodState` are null; everything
159
+ * comes from `ctx.tenant` + `ctx.dispatch` + captured config.
160
+ */
161
+ export async function buildSpMetadata(
162
+ ctx: MethodContext<SamlSpState>,
163
+ methodId: string,
164
+ config: SamlSpConfig,
165
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
166
+ if (!ctx.dispatch) {
167
+ return {
168
+ kind: "error",
169
+ error: authError.internalError(
170
+ "saml-sp: metadata route dispatched without issuer context " +
171
+ "(ctx.dispatch is null)",
172
+ ),
173
+ }
174
+ }
175
+
176
+ const spEntityId = resolveSpEntityId(
177
+ config,
178
+ ctx.dispatch.issuerUrl,
179
+ ctx.tenant.id,
180
+ methodId,
181
+ )
182
+ // SP SLS URL — same host as the ACS callback, at the public method
183
+ // mount `/m/<methodId>/sls`. Only advertised when SLO is actually
184
+ // served (idp.sloUrl set ⇒ /sls is in publicRoutes). Derived from the
185
+ // same dispatch input as the ACS so it cannot drift.
186
+ const cb = new URL(ctx.dispatch.callbackUrl)
187
+ const slsUrl = `${cb.protocol}//${cb.host}/m/${methodId}/sls`
188
+ const xml = buildSpMetadataXml({
189
+ spEntityId,
190
+ acsUrl: ctx.dispatch.callbackUrl,
191
+ ...(config.idp.sloUrl ? { slsUrl } : {}),
192
+ ...(config.idp.nameIdFormat !== undefined
193
+ ? { nameIdFormat: config.idp.nameIdFormat }
194
+ : {}),
195
+ wantAssertionsSigned: config.requireSignedAssertion ?? true,
196
+ // Truthful: advertise signing iff we actually sign AuthnRequests.
197
+ ...(config.signAuthnRequest && config.signingKey
198
+ ? { signingCertPem: config.signingKey.certPem }
199
+ : {}),
200
+ // Truthful: advertise an encryption cert iff we accept (and can
201
+ // decrypt) encrypted assertions.
202
+ ...(config.allowEncryptedAssertions && config.decryptionKey
203
+ ? { encryptionCertPem: config.decryptionKey.certPem }
204
+ : {}),
205
+ })
206
+
207
+ return {
208
+ kind: "challenge",
209
+ response: new Response(xml, {
210
+ status: 200,
211
+ headers: { "content-type": "application/samlmetadata+xml" },
212
+ }),
213
+ cache: { sMaxAge: 300 },
214
+ }
215
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `buildSamlSpMethod` — assembles the `AuthMethod` for a SAML SP
3
+ * instance. Mirrors `buildOauth2Method`: data + handler functions, no
4
+ * framework imports beyond `types/`.
5
+ *
6
+ * Routes:
7
+ * - `GET /authorize` → SP-initiated AuthnRequest redirect (implemented).
8
+ * - `GET /callback` → ACS verification gauntlet (next increment).
9
+ *
10
+ * The framework's universal callback (`/cb/<methodId>`, both GET and
11
+ * POST) always dispatches the `"GET /callback"` route key, so the ACS
12
+ * lives there rather than at a bespoke `/acs` sub-path.
13
+ */
14
+ import type { AuthMethod, MethodContext } from "../../types/method"
15
+
16
+ import { consumeAssertion } from "./acs"
17
+ import { buildAuthnRequestRedirect } from "./authnrequest"
18
+ import { buildSpMetadata } from "./metadata"
19
+ import { consumeSls } from "./sls"
20
+ import { initiateSpLogout } from "./slo-initiate"
21
+ import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
22
+
23
+ export function buildSamlSpMethod(
24
+ id: string,
25
+ kind: string,
26
+ config: SamlSpConfig,
27
+ ): AuthMethod<SamlSpProperties, SamlSpState> {
28
+ return {
29
+ id,
30
+ kind,
31
+ type: "custom",
32
+ routes: {
33
+ "GET /authorize": (ctx: MethodContext<SamlSpState>) =>
34
+ buildAuthnRequestRedirect(ctx, id, config),
35
+ "GET /callback": (ctx: MethodContext<SamlSpState>) =>
36
+ consumeAssertion(ctx, id, config),
37
+ "GET /metadata": (ctx: MethodContext<SamlSpState>) =>
38
+ buildSpMetadata(ctx, id, config),
39
+ // Front-channel Single Logout. `/sls` receives IdP LogoutRequest
40
+ // / LogoutResponse (both bindings; verified by XML-DSig, not a
41
+ // flow cookie — see sls.ts). `POST /logout` is the host-driven
42
+ // SP-initiated send (see slo-initiate.ts).
43
+ "GET /sls": (ctx: MethodContext<SamlSpState>) =>
44
+ consumeSls(ctx, id, config),
45
+ "POST /sls": (ctx: MethodContext<SamlSpState>) =>
46
+ consumeSls(ctx, id, config),
47
+ "POST /logout": (ctx: MethodContext<SamlSpState>) =>
48
+ initiateSpLogout(ctx, id, config),
49
+ },
50
+ // Anonymous, no flow cookie. `/metadata` always; the SLO routes
51
+ // (`/sls` receive + `/logout` send) only when an IdP SLO endpoint
52
+ // is configured — advertising/serving an SLO surface we cannot
53
+ // complete a round-trip on would break interop (same conservative
54
+ // gating as `unsolicitedCallback` ⇐ `idpInitiated`).
55
+ publicRoutes: [
56
+ "GET /metadata",
57
+ ...(config.idp.sloUrl
58
+ ? (["GET /sls", "POST /sls", "POST /logout"] as const)
59
+ : ([] as const)),
60
+ ],
61
+ // IdP-initiated SSO: only when this instance is configured for it.
62
+ // Absent ⇒ unsolicited Responses stay invalid_request.
63
+ unsolicitedCallback: config.idpInitiated !== undefined,
64
+ }
65
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `parseSamlIdpMetadata` — turn an IdP's SAML 2.0 metadata XML into the
3
+ * `SamlIdpConfig` shape a host stores per method instance.
4
+ *
5
+ * This is a **pure** helper exposed so a host console can offer "paste
6
+ * the IdP metadata XML / URL" instead of making operators hand-copy
7
+ * entityID, SSO URL, and signing certs. It does not import node-saml —
8
+ * metadata parsing needs no signature verification (the document is
9
+ * fetched from a trusted admin, and every assertion is independently
10
+ * verified at the ACS regardless of what the metadata claimed).
11
+ *
12
+ * Namespace-agnostic: elements are matched by local name so prefixes
13
+ * (`md:`, `ds:`, none) don't matter. Returns `Result` rather than
14
+ * throwing, matching the library's domain convention.
15
+ */
16
+ import { authError } from "../../types/error"
17
+ import type { Result } from "../../types/result"
18
+ import { err, ok } from "../../types/result"
19
+
20
+ // CJS interop per the SAML house-style note.
21
+ import xmldom from "@xmldom/xmldom"
22
+
23
+ import type { SamlIdpConfig, SamlNameIdFormat } from "./types"
24
+
25
+ const { DOMParser } = xmldom
26
+
27
+ const HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
28
+ const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
29
+
30
+ const NAME_ID_FORMAT_BY_URN: Record<string, SamlNameIdFormat> = {
31
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent": "persistent",
32
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient": "transient",
33
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress": "emailAddress",
34
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified": "unspecified",
35
+ }
36
+
37
+ function els(root: Document | Element, localName: string): Element[] {
38
+ const list = root.getElementsByTagNameNS("*", localName)
39
+ const out: Element[] = []
40
+ for (let i = 0; i < list.length; i++) {
41
+ const n = list[i]
42
+ if (n) out.push(n)
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** Pick an endpoint by binding preference (Redirect → POST → first). */
48
+ function pickEndpoint(endpoints: Element[]): string | undefined {
49
+ if (endpoints.length === 0) return undefined
50
+ const byBinding = (b: string) =>
51
+ endpoints.find((e) => e.getAttribute("Binding") === b)
52
+ const chosen =
53
+ byBinding(HTTP_REDIRECT) ?? byBinding(HTTP_POST) ?? endpoints[0]
54
+ const loc = chosen?.getAttribute("Location")
55
+ return loc && loc.length > 0 ? loc : undefined
56
+ }
57
+
58
+ /** Normalise a base64 cert body into a PEM block (64-col lines). */
59
+ function toPem(rawX509: string): string {
60
+ const b64 = rawX509.replace(/\s+/g, "")
61
+ const lines = b64.match(/.{1,64}/g) ?? [b64]
62
+ return (
63
+ "-----BEGIN CERTIFICATE-----\n" +
64
+ lines.join("\n") +
65
+ "\n-----END CERTIFICATE-----"
66
+ )
67
+ }
68
+
69
+ export function parseSamlIdpMetadata(xml: string): Result<SamlIdpConfig> {
70
+ if (typeof xml !== "string" || xml.trim().length === 0) {
71
+ return err(authError.invalidRequest("metadata XML is empty"))
72
+ }
73
+
74
+ let doc: Document
75
+ try {
76
+ doc = new DOMParser().parseFromString(
77
+ xml,
78
+ "text/xml",
79
+ ) as unknown as Document
80
+ } catch (e) {
81
+ return err(
82
+ authError.invalidRequest(
83
+ `metadata XML is not well-formed: ${
84
+ e instanceof Error ? e.message : String(e)
85
+ }`,
86
+ ),
87
+ )
88
+ }
89
+ if (!doc?.documentElement) {
90
+ return err(authError.invalidRequest("metadata XML has no root element"))
91
+ }
92
+
93
+ const entityDescriptor = els(doc, "EntityDescriptor")[0]
94
+ if (!entityDescriptor) {
95
+ return err(authError.invalidRequest("metadata has no <EntityDescriptor>"))
96
+ }
97
+ const entityId = entityDescriptor.getAttribute("entityID")
98
+ if (!entityId) {
99
+ return err(authError.invalidRequest("EntityDescriptor has no entityID"))
100
+ }
101
+
102
+ const idp = els(entityDescriptor, "IDPSSODescriptor")[0]
103
+ if (!idp) {
104
+ return err(
105
+ authError.invalidRequest(
106
+ "not an IdP metadata document (no <IDPSSODescriptor>) — this looks " +
107
+ "like SP metadata",
108
+ ),
109
+ )
110
+ }
111
+
112
+ const ssoUrl = pickEndpoint(els(idp, "SingleSignOnService"))
113
+ if (!ssoUrl) {
114
+ return err(
115
+ authError.invalidRequest(
116
+ "IDPSSODescriptor has no usable <SingleSignOnService> Location",
117
+ ),
118
+ )
119
+ }
120
+ const sloUrl = pickEndpoint(els(idp, "SingleLogoutService"))
121
+
122
+ let nameIdFormat: SamlNameIdFormat | undefined
123
+ for (const f of els(idp, "NameIDFormat")) {
124
+ const mapped = NAME_ID_FORMAT_BY_URN[(f.textContent ?? "").trim()]
125
+ if (mapped) {
126
+ nameIdFormat = mapped
127
+ break
128
+ }
129
+ }
130
+
131
+ // Signing certs: a KeyDescriptor with use="signing", or with no `use`
132
+ // (per SAML metadata spec an absent `use` means the key is valid for
133
+ // both signing and encryption).
134
+ const signingCerts: { pem: string }[] = []
135
+ for (const kd of els(idp, "KeyDescriptor")) {
136
+ const use = kd.getAttribute("use")
137
+ if (use && use !== "signing") continue
138
+ for (const cert of els(kd, "X509Certificate")) {
139
+ const body = (cert.textContent ?? "").trim()
140
+ if (body.length > 0) signingCerts.push({ pem: toPem(body) })
141
+ }
142
+ }
143
+ if (signingCerts.length === 0) {
144
+ return err(
145
+ authError.invalidRequest(
146
+ "IDPSSODescriptor has no signing <X509Certificate>",
147
+ ),
148
+ )
149
+ }
150
+
151
+ return ok({
152
+ entityId,
153
+ ssoUrl,
154
+ ...(sloUrl !== undefined ? { sloUrl } : {}),
155
+ ...(nameIdFormat !== undefined ? { nameIdFormat } : {}),
156
+ signingCerts,
157
+ })
158
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Construct a `@node-saml/node-saml` `SAML` instance from validated
3
+ * `SamlSpConfig` plus the per-request binding context (SP entityID +
4
+ * ACS URL, both derived at dispatch time per SAML-AD5).
5
+ *
6
+ * CJS interop: node-saml is CommonJS-only. Per the SAML house-style
7
+ * note, default-import then destructure rather than relying on the
8
+ * named-export heuristic.
9
+ */
10
+ import nodeSaml from "@node-saml/node-saml"
11
+
12
+ import type { MethodScratch } from "../../types/method"
13
+
14
+ import { methodScratchCacheProvider } from "./cache-provider"
15
+ import { selectActiveCertPems } from "./cert-rotation"
16
+ import type { SamlNameIdFormat, SamlSpConfig } from "./types"
17
+
18
+ const { SAML, ValidateInResponseTo } = nodeSaml
19
+ type SamlInstance = InstanceType<typeof SAML>
20
+
21
+ /** Standard SAML 2.0 NameID format URNs. */
22
+ export const NAME_ID_FORMAT_URN: Record<SamlNameIdFormat, string> = {
23
+ persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
24
+ transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
25
+ emailAddress: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
26
+ unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
27
+ }
28
+
29
+ /**
30
+ * Outstanding-request-id cache TTL. Must comfortably outlast the
31
+ * slowest realistic IdP login screen; the flow record expires on its
32
+ * own shorter clock, so this is just the upper bound on how long an
33
+ * `InResponseTo` stays accepted.
34
+ */
35
+ const IN_RESPONSE_TO_TTL_MS = 60 * 60 * 1000
36
+
37
+ export type SamlBindingContext = {
38
+ /** SP entityID — `<issuerUrl>/<tenantId>/<methodId>` (SAML-AD5). */
39
+ spEntityId: string
40
+ /** ACS URL — the framework's `/cb/<methodId>` universal callback. */
41
+ acsUrl: string
42
+ /** Per-request scratch backing the `InResponseTo` cache. */
43
+ scratch: MethodScratch
44
+ /**
45
+ * IdP-initiated mode. An unsolicited Response has no `InResponseTo`
46
+ * (there was no AuthnRequest), so `validateInResponseTo` must be
47
+ * `ifPresent` rather than `always` — otherwise node-saml throws
48
+ * "InResponseTo is missing". Signature / Issuer / Audience /
49
+ * Conditions are still fully enforced; assertion-ID replay dedup is
50
+ * layered on top by the caller (node-saml does not dedup unsolicited
51
+ * assertion IDs). Default `false` ⇒ SP-initiated, `always`
52
+ * (unchanged).
53
+ */
54
+ idpInitiated?: boolean
55
+ /**
56
+ * Logout-binding instance (the `/sls` + `/logout` paths). node-saml's
57
+ * `InResponseTo` machinery only reads the attribute off a `Response`
58
+ * root, never a `LogoutResponse`, so with the default `always` it
59
+ * throws "InResponseTo is missing" on every inbound `LogoutResponse`.
60
+ * Front-channel logout correlation is out of that scope — we
61
+ * replay-dedup the inbound `LogoutRequest @ID` via `methodScratch`
62
+ * instead — so logout instances use `never`. Overrides `idpInitiated`.
63
+ */
64
+ logout?: boolean
65
+ /**
66
+ * Per-connection SP signing material (SAML-AD: O3 — decoupled from
67
+ * the OIDC `KeyStore`; the IdP pins this cert, rotation is an
68
+ * IdP-coordination event). Present ⇒ node-saml signs the outbound
69
+ * `AuthnRequest` (HTTP-Redirect binding) **and** outbound logout
70
+ * messages (`LogoutRequest` / `LogoutResponse`) with it.
71
+ */
72
+ signing?: {
73
+ privateKeyPem: string
74
+ certPem: string
75
+ }
76
+ /**
77
+ * IdP Single Logout endpoint (`SamlSpConfig.idp.sloUrl`). Destination
78
+ * for SP-emitted `LogoutResponse` (front-channel SLO, Phase 3) and
79
+ * SP-initiated `LogoutRequest`. Only needed on the `/sls` path; the
80
+ * SSO paths leave it unset.
81
+ */
82
+ logoutUrl?: string
83
+ /**
84
+ * SP decryption private key PEM (`SamlSpConfig.decryptionKey`),
85
+ * passed through to node-saml's `decryptionPvk`. Present ⇒ node-saml
86
+ * decrypts `<saml:EncryptedAssertion>` (the decrypted assertion's
87
+ * XML-DSig is still fully enforced). Set only when the connection
88
+ * opted into `allowEncryptedAssertions`; absent ⇒ an encrypted
89
+ * assertion is rejected.
90
+ */
91
+ decryptionPvk?: string
92
+ }
93
+
94
+ /**
95
+ * Derive the per-instance SP entityID. Stable across deploys for a
96
+ * given `(issuerUrl, tenantId, methodId)` triple so IdP-side trust
97
+ * config doesn't churn.
98
+ */
99
+ export function deriveSpEntityId(
100
+ issuerUrl: string,
101
+ tenantId: string,
102
+ methodId: string,
103
+ ): string {
104
+ const base = issuerUrl.endsWith("/") ? issuerUrl.slice(0, -1) : issuerUrl
105
+ return `${base}/${tenantId}/${methodId}`
106
+ }
107
+
108
+ /**
109
+ * The SP entityID this connection actually presents — the configured
110
+ * override if the operator set one (to adopt an entityID that already
111
+ * exists at the IdP), else the derived default.
112
+ *
113
+ * **Every** consumer must go through this one function: the
114
+ * `AuthnRequest` issuer, `AudienceRestriction` validation, SP metadata,
115
+ * and logout messages all have to agree, or the IdP rejects us. That
116
+ * shared-resolution property is what the metadata anti-drift test
117
+ * guards.
118
+ */
119
+ export function resolveSpEntityId(
120
+ config: SamlSpConfig,
121
+ issuerUrl: string,
122
+ tenantId: string,
123
+ methodId: string,
124
+ ): string {
125
+ return config.spEntityId ?? deriveSpEntityId(issuerUrl, tenantId, methodId)
126
+ }
127
+
128
+ export function buildSamlInstance(
129
+ config: SamlSpConfig,
130
+ binding: SamlBindingContext,
131
+ nowMs: number,
132
+ ): SamlInstance {
133
+ const activeCerts = selectActiveCertPems(config.idp.signingCerts, nowMs)
134
+ if (activeCerts.length === 0) {
135
+ throw new Error(
136
+ "saml-sp: no IdP signing certificate is currently within its " +
137
+ "validity window — check SamlSpConfig.idp.signingCerts notBefore/notAfter.",
138
+ )
139
+ }
140
+
141
+ const identifierFormat =
142
+ config.idp.nameIdFormat !== undefined
143
+ ? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
144
+ : null
145
+
146
+ return new SAML({
147
+ // MandatorySamlOptions
148
+ idpCert: activeCerts,
149
+ issuer: binding.spEntityId,
150
+ callbackUrl: binding.acsUrl,
151
+ // IdP endpoints
152
+ entryPoint: config.idp.ssoUrl,
153
+ idpIssuer: config.idp.entityId,
154
+ audience: binding.spEntityId,
155
+ identifierFormat,
156
+ // Security posture. A signed *assertion* is mandatory (identity +
157
+ // conditions + audience all live in the signed bytes); requiring
158
+ // the outer Response to also be signed is stricter than the
159
+ // Okta/Entra default and would reject the majority of real IdPs,
160
+ // so it is not required here. SP-initiated requires an outstanding
161
+ // InResponseTo (`always`); IdP-initiated is unsolicited so it must
162
+ // be `ifPresent` — the caller layers explicit assertion-ID replay
163
+ // dedup on top for that mode.
164
+ validateInResponseTo: binding.logout
165
+ ? ValidateInResponseTo.never
166
+ : binding.idpInitiated
167
+ ? ValidateInResponseTo.ifPresent
168
+ : ValidateInResponseTo.always,
169
+ wantAssertionsSigned: config.requireSignedAssertion ?? true,
170
+ wantAuthnResponseSigned: config.requireSignedResponse ?? false,
171
+ acceptedClockSkewMs: (config.clockSkewSeconds ?? 60) * 1000,
172
+ // ForceAuthn — a request, never a guarantee. SAML gives the IdP no
173
+ // obligation to honour it and the Response carries no proof either
174
+ // way, so nothing downstream may treat it as freshness evidence.
175
+ forceAuthn: config.forceAuthn ?? false,
176
+ // RequestedAuthnContext. node-saml's own defaults are
177
+ // `disableRequestedAuthnContext: false` +
178
+ // `authnContext: [PasswordProtectedTransport]` +
179
+ // `racComparison: "exact"`, i.e. every AuthnRequest would demand
180
+ // exactly password-over-TLS. An IdP with an MFA sign-on policy can
181
+ // answer that with `NoAuthnContext` instead of a login, so we
182
+ // invert the default: send no RequestedAuthnContext unless the
183
+ // operator explicitly configured the class refs their IdP honours.
184
+ ...(config.requestedAuthnContext
185
+ ? {
186
+ disableRequestedAuthnContext: false,
187
+ authnContext: [...config.requestedAuthnContext.classRefs],
188
+ racComparison: config.requestedAuthnContext.comparison ?? "exact",
189
+ }
190
+ : { disableRequestedAuthnContext: true }),
191
+ cacheProvider: methodScratchCacheProvider(
192
+ binding.scratch,
193
+ IN_RESPONSE_TO_TTL_MS,
194
+ ),
195
+ // O3: per-connection SP signing key (opt-in). node-saml signs the
196
+ // HTTP-Redirect AuthnRequest / logout messages with this PEM keypair.
197
+ ...(binding.signing
198
+ ? {
199
+ privateKey: binding.signing.privateKeyPem,
200
+ publicCert: binding.signing.certPem,
201
+ signatureAlgorithm: "sha256" as const,
202
+ }
203
+ : {}),
204
+ // SP-emitted LogoutResponse / LogoutRequest destination (the IdP's
205
+ // SLO endpoint). node-saml's logout URL builder reads `logoutUrl`.
206
+ ...(binding.logoutUrl ? { logoutUrl: binding.logoutUrl } : {}),
207
+ // Encrypted-assertion decryption (opt-in per connection).
208
+ ...(binding.decryptionPvk ? { decryptionPvk: binding.decryptionPvk } : {}),
209
+ })
210
+ }