@_mustachio/openauth 0.12.0 → 0.13.1

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 (98) hide show
  1. package/dist/esm/domain/scim/discovery.js +206 -0
  2. package/dist/esm/domain/scim/filter.js +177 -0
  3. package/dist/esm/domain/scim/handle.js +338 -0
  4. package/dist/esm/domain/scim/patch.js +568 -0
  5. package/dist/esm/domain/scim/resource.js +256 -0
  6. package/dist/esm/http/handlers/scim.js +59 -0
  7. package/dist/esm/http/middleware/tenant.js +25 -0
  8. package/dist/esm/http/router.js +3 -0
  9. package/dist/esm/index.js +1 -0
  10. package/dist/esm/methods/saml-sp/acs.js +44 -20
  11. package/dist/esm/methods/saml-sp/attributes.js +2 -0
  12. package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
  13. package/dist/esm/methods/saml-sp/factory.js +12 -1
  14. package/dist/esm/methods/saml-sp/metadata.js +5 -3
  15. package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
  16. package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
  17. package/dist/esm/methods/saml-sp/sls.js +12 -4
  18. package/dist/esm/ports/scim-directory.js +0 -0
  19. package/dist/esm/types/error.js +5 -0
  20. package/dist/esm/types/scim.js +0 -0
  21. package/dist/types/domain/scim/discovery.d.ts +11 -0
  22. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  23. package/dist/types/domain/scim/filter.d.ts +51 -0
  24. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  25. package/dist/types/domain/scim/handle.d.ts +35 -0
  26. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  27. package/dist/types/domain/scim/patch.d.ts +73 -0
  28. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  29. package/dist/types/domain/scim/resource.d.ts +56 -0
  30. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  31. package/dist/types/http/context.d.ts +3 -0
  32. package/dist/types/http/context.d.ts.map +1 -1
  33. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  34. package/dist/types/http/handlers/scim.d.ts +3 -0
  35. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  36. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  37. package/dist/types/http/router.d.ts.map +1 -1
  38. package/dist/types/index.d.ts +2 -0
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
  41. package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
  42. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
  43. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
  44. package/dist/types/methods/saml-sp/factory.d.ts +217 -1
  45. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
  46. package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
  47. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
  48. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
  49. package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
  50. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
  51. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
  52. package/dist/types/methods/saml-sp/sls.d.ts +20 -0
  53. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
  54. package/dist/types/methods/saml-sp/types.d.ts +95 -0
  55. package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
  56. package/dist/types/ports/scim-directory.d.ts +104 -0
  57. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  58. package/dist/types/ports/session-store.d.ts.map +1 -1
  59. package/dist/types/types/error.d.ts +5 -0
  60. package/dist/types/types/error.d.ts.map +1 -1
  61. package/dist/types/types/idp.d.ts +12 -0
  62. package/dist/types/types/idp.d.ts.map +1 -1
  63. package/dist/types/types/result.d.ts.map +1 -1
  64. package/dist/types/types/scim.d.ts +269 -0
  65. package/dist/types/types/scim.d.ts.map +1 -0
  66. package/dist/types/types/tenant.d.ts +10 -0
  67. package/dist/types/types/tenant.d.ts.map +1 -1
  68. package/package.json +1 -1
  69. package/src/domain/scim/discovery.ts +262 -0
  70. package/src/domain/scim/filter.ts +295 -0
  71. package/src/domain/scim/handle.ts +597 -0
  72. package/src/domain/scim/patch.ts +807 -0
  73. package/src/domain/scim/resource.ts +358 -0
  74. package/src/http/context.ts +3 -0
  75. package/src/http/handlers/authorize.ts +0 -1
  76. package/src/http/handlers/method-route.ts +1 -4
  77. package/src/http/handlers/scim.ts +107 -0
  78. package/src/http/middleware/tenant.ts +47 -1
  79. package/src/http/router.ts +9 -0
  80. package/src/index.ts +20 -0
  81. package/src/methods/saml-sp/acs.ts +98 -49
  82. package/src/methods/saml-sp/attributes.ts +12 -3
  83. package/src/methods/saml-sp/authnrequest.ts +3 -2
  84. package/src/methods/saml-sp/factory.ts +24 -3
  85. package/src/methods/saml-sp/metadata.ts +19 -8
  86. package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
  87. package/src/methods/saml-sp/saml-instance.ts +42 -5
  88. package/src/methods/saml-sp/slo-initiate.ts +6 -3
  89. package/src/methods/saml-sp/sls.ts +35 -5
  90. package/src/methods/saml-sp/types.ts +97 -6
  91. package/src/ports/CONSISTENCY.md +28 -25
  92. package/src/ports/scim-directory.ts +166 -0
  93. package/src/ports/session-store.ts +1 -5
  94. package/src/types/error.ts +11 -0
  95. package/src/types/idp.ts +13 -3
  96. package/src/types/result.ts +1 -2
  97. package/src/types/scim.ts +280 -0
  98. package/src/types/tenant.ts +12 -10
@@ -47,7 +47,7 @@ import { authError } from "../../types/error"
47
47
  import type { MethodContext, MethodResult } from "../../types/method"
48
48
 
49
49
  import { mapProfile, type VerifiedProfile } from "./attributes"
50
- import { buildSamlInstance, deriveSpEntityId } from "./saml-instance"
50
+ import { buildSamlInstance, resolveSpEntityId } from "./saml-instance"
51
51
  import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
52
52
 
53
53
  const { DOMParser } = xmldom
@@ -81,45 +81,50 @@ type NodeSamlProfile = {
81
81
  * library/infra fault — matching the rest of this handler's
82
82
  * classification).
83
83
  */
84
- function checkRecipient(
84
+ function parseVerifiedAssertion(
85
85
  profile: NodeSamlProfile,
86
- acsUrl: string,
87
- ): MethodResult<SamlSpProperties, SamlSpState> | null {
86
+ ):
87
+ { doc: Document } | { failure: MethodResult<SamlSpProperties, SamlSpState> } {
88
88
  if (typeof profile.getAssertionXml !== "function") {
89
89
  // node-saml v5.1 populates this on a successful verify. Its
90
90
  // absence means the library contract changed under us — fail loud
91
- // rather than skip a security check we now promise.
91
+ // rather than skip security checks we now promise.
92
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
- ),
93
+ failure: {
94
+ kind: "error",
95
+ error: authError.internalError(
96
+ "saml-sp: node-saml profile exposes no getAssertionXml(); cannot " +
97
+ "perform the Recipient binding check. Refusing to authenticate.",
98
+ ),
99
+ },
98
100
  }
99
101
  }
100
-
101
- let doc: Document
102
102
  try {
103
- doc = new DOMParser().parseFromString(
104
- profile.getAssertionXml(),
105
- "text/xml",
106
- ) as unknown as Document
103
+ return {
104
+ doc: new DOMParser().parseFromString(
105
+ profile.getAssertionXml(),
106
+ "text/xml",
107
+ ) as unknown as Document,
108
+ }
107
109
  } catch (e) {
108
110
  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
- ),
111
+ failure: {
112
+ kind: "error",
113
+ error: authError.internalError(
114
+ "saml-sp: failed to parse the verified assertion",
115
+ e,
116
+ ),
117
+ },
115
118
  }
116
119
  }
120
+ }
117
121
 
122
+ function checkRecipient(
123
+ doc: Document,
124
+ acsUrl: string,
125
+ ): MethodResult<SamlSpProperties, SamlSpState> | null {
118
126
  // Namespace-agnostic: match by local name regardless of prefix.
119
- const nodes = doc.getElementsByTagNameNS(
120
- "*",
121
- "SubjectConfirmationData",
122
- )
127
+ const nodes = doc.getElementsByTagNameNS("*", "SubjectConfirmationData")
123
128
  const recipients: string[] = []
124
129
  for (let i = 0; i < nodes.length; i++) {
125
130
  const r = nodes[i]?.getAttribute("Recipient")
@@ -145,24 +150,14 @@ function checkRecipient(
145
150
 
146
151
  /**
147
152
  * 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).
153
+ * tightest `NotOnOrAfter` from the **verified** assertion (the same
154
+ * parsed document every other check reads). Returns `null` when the
155
+ * assertion carries no `@ID` — the caller treats that as a fail-loud
156
+ * error rather than skipping replay protection we promised.
152
157
  */
153
158
  function extractReplayInfo(
154
- profile: NodeSamlProfile,
159
+ doc: Document,
155
160
  ): { 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
161
  const root = doc.documentElement
167
162
  const assertionId = root?.getAttribute("ID") ?? ""
168
163
  if (!assertionId) return null
@@ -173,12 +168,57 @@ function extractReplayInfo(
173
168
  const v = els[i]?.getAttribute("NotOnOrAfter")
174
169
  if (!v) continue
175
170
  const ms = Date.parse(v)
176
- if (!Number.isNaN(ms)) earliest = earliest === null ? ms : Math.min(earliest, ms)
171
+ if (!Number.isNaN(ms))
172
+ earliest = earliest === null ? ms : Math.min(earliest, ms)
177
173
  }
178
174
  }
179
175
  return { assertionId, notOnOrAfterMs: earliest }
180
176
  }
181
177
 
178
+ /**
179
+ * Read the `<AuthnStatement>` facts the host needs but node-saml's
180
+ * `Profile` does not carry (`types.d.ts` exposes `nameID`,
181
+ * `nameIDFormat`, `sessionIndex` and the flattened attributes, and
182
+ * nothing else structural).
183
+ *
184
+ * Read from the **verified** assertion document — never the unsigned
185
+ * outer Response — for the same reason `checkRecipient` is: an
186
+ * attacker-controlled value outside the signature is not a fact.
187
+ *
188
+ * All three are optional in SAML, so every field is best-effort; the
189
+ * caller falls back rather than failing, since none of them is a
190
+ * security control on its own.
191
+ */
192
+ function extractAuthnStatement(doc: Document): {
193
+ authnInstant?: string
194
+ sessionNotOnOrAfter?: number
195
+ authnContextClassRef?: string
196
+ } {
197
+ const out: {
198
+ authnInstant?: string
199
+ sessionNotOnOrAfter?: number
200
+ authnContextClassRef?: string
201
+ } = {}
202
+ const stmt = doc.getElementsByTagNameNS("*", "AuthnStatement")[0]
203
+ if (!stmt) return out
204
+
205
+ const instant = stmt.getAttribute("AuthnInstant")
206
+ if (instant) out.authnInstant = instant
207
+
208
+ const sessionExpiry = stmt.getAttribute("SessionNotOnOrAfter")
209
+ if (sessionExpiry) {
210
+ const ms = Date.parse(sessionExpiry)
211
+ if (!Number.isNaN(ms)) out.sessionNotOnOrAfter = ms
212
+ }
213
+
214
+ const ref = stmt
215
+ .getElementsByTagNameNS("*", "AuthnContextClassRef")[0]
216
+ ?.textContent?.trim()
217
+ if (ref) out.authnContextClassRef = ref
218
+
219
+ return out
220
+ }
221
+
182
222
  export async function consumeAssertion(
183
223
  ctx: MethodContext<SamlSpState>,
184
224
  methodId: string,
@@ -212,7 +252,8 @@ export async function consumeAssertion(
212
252
  }
213
253
  }
214
254
  // Same derivation as AuthnRequest / metadata — no drift.
215
- spEntityId = deriveSpEntityId(
255
+ spEntityId = resolveSpEntityId(
256
+ config,
216
257
  ctx.dispatch.issuerUrl,
217
258
  ctx.tenant.id,
218
259
  methodId,
@@ -299,10 +340,7 @@ export async function consumeAssertion(
299
340
  // arrives but no decryptionPvk is configured. Give the operator a
300
341
  // signal that the *connection* is mis/under-configured rather than
301
342
  // a generic "assertion rejected".
302
- if (
303
- !config.allowEncryptedAssertions &&
304
- msg.includes("No decryption key")
305
- ) {
343
+ if (!config.allowEncryptedAssertions && msg.includes("No decryption key")) {
306
344
  return {
307
345
  kind: "denied",
308
346
  reason:
@@ -318,17 +356,25 @@ export async function consumeAssertion(
318
356
  return { kind: "denied", reason: "assertion produced no usable subject" }
319
357
  }
320
358
 
359
+ // One parse of the verified assertion, shared by every check below
360
+ // (Recipient binding, IdP-init replay dedup, AuthnStatement facts) —
361
+ // they all read the same signed bytes, so parsing once is both
362
+ // cheaper and impossible to accidentally diverge.
363
+ const parsed = parseVerifiedAssertion(profile)
364
+ if ("failure" in parsed) return parsed.failure
365
+ const assertionDoc = parsed.doc
366
+
321
367
  // Gauntlet item 6 — node-saml does not enforce @Recipient; we do,
322
368
  // against the exact ACS URL the IdP saw (committed for SP-init,
323
369
  // derived for IdP-init — identical value either way).
324
- const recipientFailure = checkRecipient(profile, acsUrl)
370
+ const recipientFailure = checkRecipient(assertionDoc, acsUrl)
325
371
  if (recipientFailure) return recipientFailure
326
372
 
327
373
  // IdP-init replay: no InResponseTo single-use to lean on, so dedup
328
374
  // the signed assertion's @ID. SP-init does not need this (the
329
375
  // request id is already single-use).
330
376
  if (idpInitiated) {
331
- const replay = extractReplayInfo(profile)
377
+ const replay = extractReplayInfo(assertionDoc)
332
378
  if (!replay) {
333
379
  return {
334
380
  kind: "error",
@@ -367,12 +413,15 @@ export async function consumeAssertion(
367
413
  }
368
414
  }
369
415
 
416
+ const authnStatement = extractAuthnStatement(assertionDoc)
417
+
370
418
  const verified: VerifiedProfile = {
371
419
  nameID: profile.nameID,
372
420
  nameIDFormat: profile.nameIDFormat ?? "",
373
421
  ...(profile.sessionIndex !== undefined
374
422
  ? { sessionIndex: profile.sessionIndex }
375
423
  : {}),
424
+ ...authnStatement,
376
425
  attributes: profile.attributes ?? {},
377
426
  responseXml: profile.getSamlResponseXml?.() ?? "",
378
427
  }
@@ -27,7 +27,12 @@ export type VerifiedProfile = {
27
27
  nameIDFormat: string
28
28
  sessionIndex?: string
29
29
  attributes: Record<string, unknown>
30
+ /** `AuthnStatement/@AuthnInstant`, raw XSD dateTime. */
30
31
  authnInstant?: string
32
+ /** `AuthnStatement/@SessionNotOnOrAfter`, already parsed to Unix ms. */
33
+ sessionNotOnOrAfter?: number
34
+ /** `AuthnContext/AuthnContextClassRef` — what the IdP actually asserted. */
35
+ authnContextClassRef?: string
31
36
  responseXml: string
32
37
  }
33
38
 
@@ -107,9 +112,7 @@ export function mapProfile(
107
112
  put("name", mapping.name)
108
113
  put("groups", mapping.groups)
109
114
  if (mapping.emailVerified) {
110
- attributes["emailVerified"] = mapping.emailVerified.value
111
- ? "true"
112
- : "false"
115
+ attributes["emailVerified"] = mapping.emailVerified.value ? "true" : "false"
113
116
  }
114
117
  for (const [key, ref] of Object.entries(mapping.custom ?? {})) {
115
118
  put(key, ref)
@@ -131,6 +134,12 @@ export function mapProfile(
131
134
  ? { sessionIndex: profile.sessionIndex }
132
135
  : {}),
133
136
  authnInstant: Number.isNaN(authnInstant) ? Date.now() : authnInstant,
137
+ ...(profile.sessionNotOnOrAfter !== undefined
138
+ ? { sessionNotOnOrAfter: profile.sessionNotOnOrAfter }
139
+ : {}),
140
+ ...(profile.authnContextClassRef !== undefined
141
+ ? { authnContextClassRef: profile.authnContextClassRef }
142
+ : {}),
134
143
  raw: { responseXml: profile.responseXml },
135
144
  },
136
145
  }
@@ -13,7 +13,7 @@ import { authError } from "../../types/error"
13
13
  import type { MethodContext, MethodResult } from "../../types/method"
14
14
  import { isErr } from "../../types/result"
15
15
 
16
- import { buildSamlInstance, deriveSpEntityId } from "./saml-instance"
16
+ import { buildSamlInstance, resolveSpEntityId } from "./saml-instance"
17
17
  import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
18
18
 
19
19
  export async function buildAuthnRequestRedirect(
@@ -43,7 +43,8 @@ export async function buildAuthnRequestRedirect(
43
43
  }
44
44
  }
45
45
 
46
- const spEntityId = deriveSpEntityId(
46
+ const spEntityId = resolveSpEntityId(
47
+ config,
47
48
  ctx.dispatch.issuerUrl,
48
49
  ctx.tenant.id,
49
50
  methodId,
@@ -80,17 +80,38 @@ const samlSpConfigSchema = z
80
80
  .optional(),
81
81
  idpInitiated: idpInitiatedSchema.optional(),
82
82
  clockSkewSeconds: z.number().int().nonnegative().optional(),
83
+ spEntityId: z.string().min(1).optional(),
84
+ forceAuthn: z.boolean().optional(),
85
+ requestedAuthnContext: z
86
+ .object({
87
+ classRefs: z.array(z.string().min(1)).min(1),
88
+ comparison: z
89
+ .enum(["exact", "minimum", "maximum", "better"])
90
+ .optional(),
91
+ })
92
+ .optional(),
93
+ requireSignedAssertion: z.boolean().optional(),
94
+ requireSignedResponse: z.boolean().optional(),
83
95
  })
84
96
  .refine((c) => !c.signAuthnRequest || c.signingKey !== undefined, {
85
97
  message: "signingKey is required when signAuthnRequest is true",
86
98
  path: ["signingKey"],
87
99
  })
100
+ .refine((c) => !c.allowEncryptedAssertions || c.decryptionKey !== undefined, {
101
+ message: "decryptionKey is required when allowEncryptedAssertions is true",
102
+ path: ["decryptionKey"],
103
+ })
104
+ // An assertion nobody signed, inside a Response nobody signed, is
105
+ // unauthenticated XML. Refuse the combination outright rather than
106
+ // let a connection be configured into accepting anything.
88
107
  .refine(
89
- (c) => !c.allowEncryptedAssertions || c.decryptionKey !== undefined,
108
+ (c) =>
109
+ (c.requireSignedAssertion ?? true) || c.requireSignedResponse === true,
90
110
  {
91
111
  message:
92
- "decryptionKey is required when allowEncryptedAssertions is true",
93
- path: ["decryptionKey"],
112
+ "requireSignedAssertion may only be false when requireSignedResponse " +
113
+ "is true — at least one signature is mandatory",
114
+ path: ["requireSignedAssertion"],
94
115
  },
95
116
  )
96
117
 
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * **Conformance invariant:** the `entityID` and ACS `Location` are
10
10
  * derived from the *same* inputs the live AuthnRequest / ACS path uses
11
- * (`deriveSpEntityId(issuerUrl, tenantId, methodId)` and
11
+ * (`resolveSpEntityId(config, issuerUrl, tenantId, methodId)` and
12
12
  * `ctx.dispatch.callbackUrl`). The metadata therefore describes exactly
13
13
  * what the runtime accepts — never an independently re-specified value
14
14
  * that could drift. `metadata.test.ts` asserts this equality against
@@ -18,13 +18,14 @@
18
18
  * (advertising a cert we cannot use would be the bug);
19
19
  * `SingleLogoutService` only when an IdP SLO endpoint is configured
20
20
  * and the `/sls` route is therefore served (advertising an endpoint we
21
- * do not serve would break interop). `AuthnRequestsSigned` /
22
- * `WantAssertionsSigned="true"` truthfully state actual behaviour.
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.
23
24
  */
24
25
  import { authError } from "../../types/error"
25
26
  import type { MethodContext, MethodResult } from "../../types/method"
26
27
 
27
- import { deriveSpEntityId } from "./saml-instance"
28
+ import { resolveSpEntityId } from "./saml-instance"
28
29
  import type {
29
30
  SamlNameIdFormat,
30
31
  SamlSpConfig,
@@ -40,8 +41,7 @@ const NAME_ID_FORMAT_URN: Record<SamlNameIdFormat, string> = {
40
41
  }
41
42
 
42
43
  const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
43
- const HTTP_REDIRECT =
44
- "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
44
+ const HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
45
45
 
46
46
  /** Minimal XML attribute/text escaping for URL-shaped values. */
47
47
  function xmlEscape(s: string): string {
@@ -79,6 +79,14 @@ export type SpMetadataInput = {
79
79
  * what-we-serve invariant as the signing cert.
80
80
  */
81
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
82
90
  }
83
91
 
84
92
  /** PEM cert body → bare base64 (SAML metadata X509Certificate form). */
@@ -97,6 +105,7 @@ export function buildSpMetadataXml(input: SpMetadataInput): string {
97
105
  const entityId = xmlEscape(input.spEntityId)
98
106
  const acs = xmlEscape(input.acsUrl)
99
107
  const signed = input.signingCertPem !== undefined
108
+ const wantAssertionsSigned = input.wantAssertionsSigned ?? true
100
109
  const nameIdLine =
101
110
  input.nameIdFormat !== undefined
102
111
  ? `\n <md:NameIDFormat>${NAME_ID_FORMAT_URN[input.nameIdFormat]}</md:NameIDFormat>`
@@ -132,7 +141,7 @@ export function buildSpMetadataXml(input: SpMetadataInput): string {
132
141
  `xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ` +
133
142
  `entityID="${entityId}">\n` +
134
143
  ` <md:SPSSODescriptor AuthnRequestsSigned="${signed}" ` +
135
- `WantAssertionsSigned="true" ` +
144
+ `WantAssertionsSigned="${wantAssertionsSigned}" ` +
136
145
  `protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">` +
137
146
  keyDescriptor +
138
147
  sls +
@@ -164,7 +173,8 @@ export async function buildSpMetadata(
164
173
  }
165
174
  }
166
175
 
167
- const spEntityId = deriveSpEntityId(
176
+ const spEntityId = resolveSpEntityId(
177
+ config,
168
178
  ctx.dispatch.issuerUrl,
169
179
  ctx.tenant.id,
170
180
  methodId,
@@ -182,6 +192,7 @@ export async function buildSpMetadata(
182
192
  ...(config.idp.nameIdFormat !== undefined
183
193
  ? { nameIdFormat: config.idp.nameIdFormat }
184
194
  : {}),
195
+ wantAssertionsSigned: config.requireSignedAssertion ?? true,
185
196
  // Truthful: advertise signing iff we actually sign AuthnRequests.
186
197
  ...(config.signAuthnRequest && config.signingKey
187
198
  ? { signingCertPem: config.signingKey.certPem }
@@ -66,9 +66,7 @@ function toPem(rawX509: string): string {
66
66
  )
67
67
  }
68
68
 
69
- export function parseSamlIdpMetadata(
70
- xml: string,
71
- ): Result<SamlIdpConfig> {
69
+ export function parseSamlIdpMetadata(xml: string): Result<SamlIdpConfig> {
72
70
  if (typeof xml !== "string" || xml.trim().length === 0) {
73
71
  return err(authError.invalidRequest("metadata XML is empty"))
74
72
  }
@@ -94,15 +92,11 @@ export function parseSamlIdpMetadata(
94
92
 
95
93
  const entityDescriptor = els(doc, "EntityDescriptor")[0]
96
94
  if (!entityDescriptor) {
97
- return err(
98
- authError.invalidRequest("metadata has no <EntityDescriptor>"),
99
- )
95
+ return err(authError.invalidRequest("metadata has no <EntityDescriptor>"))
100
96
  }
101
97
  const entityId = entityDescriptor.getAttribute("entityID")
102
98
  if (!entityId) {
103
- return err(
104
- authError.invalidRequest("EntityDescriptor has no entityID"),
105
- )
99
+ return err(authError.invalidRequest("EntityDescriptor has no entityID"))
106
100
  }
107
101
 
108
102
  const idp = els(entityDescriptor, "IDPSSODescriptor")[0]
@@ -105,6 +105,26 @@ export function deriveSpEntityId(
105
105
  return `${base}/${tenantId}/${methodId}`
106
106
  }
107
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
+
108
128
  export function buildSamlInstance(
109
129
  config: SamlSpConfig,
110
130
  binding: SamlBindingContext,
@@ -146,9 +166,28 @@ export function buildSamlInstance(
146
166
  : binding.idpInitiated
147
167
  ? ValidateInResponseTo.ifPresent
148
168
  : ValidateInResponseTo.always,
149
- wantAssertionsSigned: true,
150
- wantAuthnResponseSigned: false,
169
+ wantAssertionsSigned: config.requireSignedAssertion ?? true,
170
+ wantAuthnResponseSigned: config.requireSignedResponse ?? false,
151
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 }),
152
191
  cacheProvider: methodScratchCacheProvider(
153
192
  binding.scratch,
154
193
  IN_RESPONSE_TO_TTL_MS,
@@ -166,8 +205,6 @@ export function buildSamlInstance(
166
205
  // SLO endpoint). node-saml's logout URL builder reads `logoutUrl`.
167
206
  ...(binding.logoutUrl ? { logoutUrl: binding.logoutUrl } : {}),
168
207
  // Encrypted-assertion decryption (opt-in per connection).
169
- ...(binding.decryptionPvk
170
- ? { decryptionPvk: binding.decryptionPvk }
171
- : {}),
208
+ ...(binding.decryptionPvk ? { decryptionPvk: binding.decryptionPvk } : {}),
172
209
  })
173
210
  }
@@ -32,7 +32,7 @@ import type { MethodContext, MethodResult } from "../../types/method"
32
32
 
33
33
  import {
34
34
  buildSamlInstance,
35
- deriveSpEntityId,
35
+ resolveSpEntityId,
36
36
  NAME_ID_FORMAT_URN,
37
37
  } from "./saml-instance"
38
38
  import type {
@@ -78,7 +78,9 @@ export async function initiateSpLogout(
78
78
  } catch {
79
79
  return {
80
80
  kind: "error",
81
- error: authError.internalError("saml-sp: /logout could not read POST body"),
81
+ error: authError.internalError(
82
+ "saml-sp: /logout could not read POST body",
83
+ ),
82
84
  }
83
85
  }
84
86
 
@@ -112,7 +114,8 @@ export async function initiateSpLogout(
112
114
  ? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
113
115
  : NAME_ID_FORMAT_URN.persistent
114
116
 
115
- const spEntityId = deriveSpEntityId(
117
+ const spEntityId = resolveSpEntityId(
118
+ config,
116
119
  ctx.dispatch.issuerUrl,
117
120
  ctx.tenant.id,
118
121
  methodId,
@@ -33,7 +33,7 @@ import { authError } from "../../types/error"
33
33
  import type { MethodContext, MethodResult } from "../../types/method"
34
34
  import { isErr } from "../../types/result"
35
35
 
36
- import { buildSamlInstance, deriveSpEntityId } from "./saml-instance"
36
+ import { buildSamlInstance, resolveSpEntityId } from "./saml-instance"
37
37
  import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
38
38
 
39
39
  /** Minimal view of node-saml's `Profile` — never leaked publicly. */
@@ -53,6 +53,34 @@ type NodeSamlProfile = {
53
53
  */
54
54
  const SLO_REPLAY_HORIZON_MS = 10 * 60_000
55
55
 
56
+ /**
57
+ * The query string exactly as transmitted.
58
+ *
59
+ * The HTTP-Redirect binding signs the raw query octets (OASIS SAML 2.0
60
+ * Bindings §3.4.4.1), so verification has to see the bytes the IdP
61
+ * signed rather than a re-encoding of them.
62
+ *
63
+ * This used to read `new URL(request.url).search`, which round-trips the
64
+ * query through whatever encoder the runtime ships. Whether that is
65
+ * byte-preserving turns out to be a property of the *runtime*, not of
66
+ * the request: it holds on Bun 1.1 and does not on Bun 1.4, where every
67
+ * inbound redirect-binding logout became a 403 signature failure while
68
+ * the POST binding — which never touches the query — kept working. The
69
+ * local suite passed throughout, because it ran the older Bun.
70
+ *
71
+ * Slicing at the first `?` puts no encoder in the path, so it cannot
72
+ * drift. A fragment is never transmitted to a server, but trimming one
73
+ * is cheap insurance against a caller that synthesises a URL by hand.
74
+ */
75
+ export function rawQueryString(requestUrl: string): string {
76
+ const start = requestUrl.indexOf("?")
77
+ if (start < 0) return ""
78
+ const fragment = requestUrl.indexOf("#", start)
79
+ return fragment < 0
80
+ ? requestUrl.slice(start + 1)
81
+ : requestUrl.slice(start + 1, fragment)
82
+ }
83
+
56
84
  export async function consumeSls(
57
85
  ctx: MethodContext<SamlSpState>,
58
86
  methodId: string,
@@ -79,17 +107,19 @@ export async function consumeSls(
79
107
  }
80
108
  }
81
109
 
82
- const spEntityId = deriveSpEntityId(
110
+ const spEntityId = resolveSpEntityId(
111
+ config,
83
112
  ctx.dispatch.issuerUrl,
84
113
  ctx.tenant.id,
85
114
  methodId,
86
115
  )
87
116
 
88
117
  // Read both bindings: HTTP-Redirect (GET, signed query string) and
89
- // HTTP-POST (form body). The raw query string is required to verify a
90
- // redirect-binding signature (it is computed over the exact bytes).
118
+ // HTTP-POST (form body). `url` is for reading individual parameters,
119
+ // where decoding is what we want; the signature input must be the raw
120
+ // transmitted bytes instead — see `rawQueryString`.
91
121
  const url = new URL(ctx.request.url)
92
- const originalQuery = url.search.replace(/^\?/, "")
122
+ const originalQuery = rawQueryString(ctx.request.url)
93
123
  let samlRequest: string | null
94
124
  let samlResponse: string | null
95
125
  let relayState: string | null