@_mustachio/openauth 0.12.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 (95) 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 +2 -2
  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.map +1 -1
  53. package/dist/types/methods/saml-sp/types.d.ts +95 -0
  54. package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
  55. package/dist/types/ports/scim-directory.d.ts +104 -0
  56. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  57. package/dist/types/ports/session-store.d.ts.map +1 -1
  58. package/dist/types/types/error.d.ts +5 -0
  59. package/dist/types/types/error.d.ts.map +1 -1
  60. package/dist/types/types/idp.d.ts +12 -0
  61. package/dist/types/types/idp.d.ts.map +1 -1
  62. package/dist/types/types/scim.d.ts +269 -0
  63. package/dist/types/types/scim.d.ts.map +1 -0
  64. package/dist/types/types/tenant.d.ts +10 -0
  65. package/dist/types/types/tenant.d.ts.map +1 -1
  66. package/package.json +1 -1
  67. package/src/domain/scim/discovery.ts +262 -0
  68. package/src/domain/scim/filter.ts +295 -0
  69. package/src/domain/scim/handle.ts +597 -0
  70. package/src/domain/scim/patch.ts +807 -0
  71. package/src/domain/scim/resource.ts +358 -0
  72. package/src/http/context.ts +3 -0
  73. package/src/http/handlers/authorize.ts +0 -1
  74. package/src/http/handlers/method-route.ts +1 -4
  75. package/src/http/handlers/scim.ts +107 -0
  76. package/src/http/middleware/tenant.ts +47 -1
  77. package/src/http/router.ts +9 -0
  78. package/src/index.ts +20 -0
  79. package/src/methods/saml-sp/acs.ts +99 -49
  80. package/src/methods/saml-sp/attributes.ts +12 -3
  81. package/src/methods/saml-sp/authnrequest.ts +3 -2
  82. package/src/methods/saml-sp/factory.ts +24 -3
  83. package/src/methods/saml-sp/metadata.ts +19 -8
  84. package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
  85. package/src/methods/saml-sp/saml-instance.ts +42 -5
  86. package/src/methods/saml-sp/slo-initiate.ts +6 -3
  87. package/src/methods/saml-sp/sls.ts +3 -2
  88. package/src/methods/saml-sp/types.ts +95 -0
  89. package/src/ports/CONSISTENCY.md +28 -25
  90. package/src/ports/scim-directory.ts +166 -0
  91. package/src/ports/session-store.ts +1 -5
  92. package/src/types/error.ts +11 -0
  93. package/src/types/idp.ts +12 -0
  94. package/src/types/scim.ts +280 -0
  95. package/src/types/tenant.ts +10 -0
@@ -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,51 @@ 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 }
88
+ | { failure: MethodResult<SamlSpProperties, SamlSpState> } {
88
89
  if (typeof profile.getAssertionXml !== "function") {
89
90
  // node-saml v5.1 populates this on a successful verify. Its
90
91
  // absence means the library contract changed under us — fail loud
91
- // rather than skip a security check we now promise.
92
+ // rather than skip security checks we now promise.
92
93
  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
- ),
94
+ failure: {
95
+ kind: "error",
96
+ error: authError.internalError(
97
+ "saml-sp: node-saml profile exposes no getAssertionXml(); cannot " +
98
+ "perform the Recipient binding check. Refusing to authenticate.",
99
+ ),
100
+ },
98
101
  }
99
102
  }
100
-
101
- let doc: Document
102
103
  try {
103
- doc = new DOMParser().parseFromString(
104
- profile.getAssertionXml(),
105
- "text/xml",
106
- ) as unknown as Document
104
+ return {
105
+ doc: new DOMParser().parseFromString(
106
+ profile.getAssertionXml(),
107
+ "text/xml",
108
+ ) as unknown as Document,
109
+ }
107
110
  } catch (e) {
108
111
  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
- ),
112
+ failure: {
113
+ kind: "error",
114
+ error: authError.internalError(
115
+ "saml-sp: failed to parse the verified assertion",
116
+ e,
117
+ ),
118
+ },
115
119
  }
116
120
  }
121
+ }
117
122
 
123
+ function checkRecipient(
124
+ doc: Document,
125
+ acsUrl: string,
126
+ ): MethodResult<SamlSpProperties, SamlSpState> | null {
118
127
  // Namespace-agnostic: match by local name regardless of prefix.
119
- const nodes = doc.getElementsByTagNameNS(
120
- "*",
121
- "SubjectConfirmationData",
122
- )
128
+ const nodes = doc.getElementsByTagNameNS("*", "SubjectConfirmationData")
123
129
  const recipients: string[] = []
124
130
  for (let i = 0; i < nodes.length; i++) {
125
131
  const r = nodes[i]?.getAttribute("Recipient")
@@ -145,24 +151,14 @@ function checkRecipient(
145
151
 
146
152
  /**
147
153
  * 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).
154
+ * tightest `NotOnOrAfter` from the **verified** assertion (the same
155
+ * parsed document every other check reads). Returns `null` when the
156
+ * assertion carries no `@ID` — the caller treats that as a fail-loud
157
+ * error rather than skipping replay protection we promised.
152
158
  */
153
159
  function extractReplayInfo(
154
- profile: NodeSamlProfile,
160
+ doc: Document,
155
161
  ): { 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
162
  const root = doc.documentElement
167
163
  const assertionId = root?.getAttribute("ID") ?? ""
168
164
  if (!assertionId) return null
@@ -173,12 +169,57 @@ function extractReplayInfo(
173
169
  const v = els[i]?.getAttribute("NotOnOrAfter")
174
170
  if (!v) continue
175
171
  const ms = Date.parse(v)
176
- if (!Number.isNaN(ms)) earliest = earliest === null ? ms : Math.min(earliest, ms)
172
+ if (!Number.isNaN(ms))
173
+ earliest = earliest === null ? ms : Math.min(earliest, ms)
177
174
  }
178
175
  }
179
176
  return { assertionId, notOnOrAfterMs: earliest }
180
177
  }
181
178
 
179
+ /**
180
+ * Read the `<AuthnStatement>` facts the host needs but node-saml's
181
+ * `Profile` does not carry (`types.d.ts` exposes `nameID`,
182
+ * `nameIDFormat`, `sessionIndex` and the flattened attributes, and
183
+ * nothing else structural).
184
+ *
185
+ * Read from the **verified** assertion document — never the unsigned
186
+ * outer Response — for the same reason `checkRecipient` is: an
187
+ * attacker-controlled value outside the signature is not a fact.
188
+ *
189
+ * All three are optional in SAML, so every field is best-effort; the
190
+ * caller falls back rather than failing, since none of them is a
191
+ * security control on its own.
192
+ */
193
+ function extractAuthnStatement(doc: Document): {
194
+ authnInstant?: string
195
+ sessionNotOnOrAfter?: number
196
+ authnContextClassRef?: string
197
+ } {
198
+ const out: {
199
+ authnInstant?: string
200
+ sessionNotOnOrAfter?: number
201
+ authnContextClassRef?: string
202
+ } = {}
203
+ const stmt = doc.getElementsByTagNameNS("*", "AuthnStatement")[0]
204
+ if (!stmt) return out
205
+
206
+ const instant = stmt.getAttribute("AuthnInstant")
207
+ if (instant) out.authnInstant = instant
208
+
209
+ const sessionExpiry = stmt.getAttribute("SessionNotOnOrAfter")
210
+ if (sessionExpiry) {
211
+ const ms = Date.parse(sessionExpiry)
212
+ if (!Number.isNaN(ms)) out.sessionNotOnOrAfter = ms
213
+ }
214
+
215
+ const ref = stmt
216
+ .getElementsByTagNameNS("*", "AuthnContextClassRef")[0]
217
+ ?.textContent?.trim()
218
+ if (ref) out.authnContextClassRef = ref
219
+
220
+ return out
221
+ }
222
+
182
223
  export async function consumeAssertion(
183
224
  ctx: MethodContext<SamlSpState>,
184
225
  methodId: string,
@@ -212,7 +253,8 @@ export async function consumeAssertion(
212
253
  }
213
254
  }
214
255
  // Same derivation as AuthnRequest / metadata — no drift.
215
- spEntityId = deriveSpEntityId(
256
+ spEntityId = resolveSpEntityId(
257
+ config,
216
258
  ctx.dispatch.issuerUrl,
217
259
  ctx.tenant.id,
218
260
  methodId,
@@ -299,10 +341,7 @@ export async function consumeAssertion(
299
341
  // arrives but no decryptionPvk is configured. Give the operator a
300
342
  // signal that the *connection* is mis/under-configured rather than
301
343
  // a generic "assertion rejected".
302
- if (
303
- !config.allowEncryptedAssertions &&
304
- msg.includes("No decryption key")
305
- ) {
344
+ if (!config.allowEncryptedAssertions && msg.includes("No decryption key")) {
306
345
  return {
307
346
  kind: "denied",
308
347
  reason:
@@ -318,17 +357,25 @@ export async function consumeAssertion(
318
357
  return { kind: "denied", reason: "assertion produced no usable subject" }
319
358
  }
320
359
 
360
+ // One parse of the verified assertion, shared by every check below
361
+ // (Recipient binding, IdP-init replay dedup, AuthnStatement facts) —
362
+ // they all read the same signed bytes, so parsing once is both
363
+ // cheaper and impossible to accidentally diverge.
364
+ const parsed = parseVerifiedAssertion(profile)
365
+ if ("failure" in parsed) return parsed.failure
366
+ const assertionDoc = parsed.doc
367
+
321
368
  // Gauntlet item 6 — node-saml does not enforce @Recipient; we do,
322
369
  // against the exact ACS URL the IdP saw (committed for SP-init,
323
370
  // derived for IdP-init — identical value either way).
324
- const recipientFailure = checkRecipient(profile, acsUrl)
371
+ const recipientFailure = checkRecipient(assertionDoc, acsUrl)
325
372
  if (recipientFailure) return recipientFailure
326
373
 
327
374
  // IdP-init replay: no InResponseTo single-use to lean on, so dedup
328
375
  // the signed assertion's @ID. SP-init does not need this (the
329
376
  // request id is already single-use).
330
377
  if (idpInitiated) {
331
- const replay = extractReplayInfo(profile)
378
+ const replay = extractReplayInfo(assertionDoc)
332
379
  if (!replay) {
333
380
  return {
334
381
  kind: "error",
@@ -367,12 +414,15 @@ export async function consumeAssertion(
367
414
  }
368
415
  }
369
416
 
417
+ const authnStatement = extractAuthnStatement(assertionDoc)
418
+
370
419
  const verified: VerifiedProfile = {
371
420
  nameID: profile.nameID,
372
421
  nameIDFormat: profile.nameIDFormat ?? "",
373
422
  ...(profile.sessionIndex !== undefined
374
423
  ? { sessionIndex: profile.sessionIndex }
375
424
  : {}),
425
+ ...authnStatement,
376
426
  attributes: profile.attributes ?? {},
377
427
  responseXml: profile.getSamlResponseXml?.() ?? "",
378
428
  }
@@ -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. */
@@ -79,7 +79,8 @@ export async function consumeSls(
79
79
  }
80
80
  }
81
81
 
82
- const spEntityId = deriveSpEntityId(
82
+ const spEntityId = resolveSpEntityId(
83
+ config,
83
84
  ctx.dispatch.issuerUrl,
84
85
  ctx.tenant.id,
85
86
  methodId,
@@ -152,6 +152,80 @@ export type SamlSpConfig = {
152
152
  idpInitiated?: SamlIdpInitiatedConfig
153
153
  /** Clock skew allowance for `NotBefore` / `NotOnOrAfter`. Seconds. */
154
154
  clockSkewSeconds?: number
155
+ /**
156
+ * Override the derived SP entityID.
157
+ *
158
+ * By default the entityID is derived as
159
+ * `<issuerUrl>/<tenantId>/<methodId>` (SAML-AD5) — stable, no config
160
+ * required, and guaranteed to match what SP metadata publishes. Set
161
+ * this **only** to adopt an entityID that already exists at the IdP,
162
+ * so an existing SAML app can be migrated without the customer
163
+ * editing their production SSO config.
164
+ *
165
+ * The override flows through every consumer at once — `AuthnRequest`
166
+ * issuer, `AudienceRestriction` validation, SP metadata, and logout
167
+ * messages — so the anti-drift invariant holds either way. Changing
168
+ * it on a live connection invalidates the IdP-side trust config;
169
+ * treat it as an IdP-coordination event.
170
+ */
171
+ spEntityId?: string
172
+ /**
173
+ * Set `ForceAuthn="true"` on the outbound `AuthnRequest`, asking the
174
+ * IdP to re-authenticate the user even if it has a live session.
175
+ * Default `false`.
176
+ *
177
+ * Note that this is a *request*: SAML gives the IdP no obligation to
178
+ * honour it, and there is no way to verify from the Response that it
179
+ * did. Do not treat a successful assertion as proof of fresh
180
+ * authentication.
181
+ */
182
+ forceAuthn?: boolean
183
+ /**
184
+ * Request specific authentication context classes (e.g. MFA) from
185
+ * the IdP via `<RequestedAuthnContext>`.
186
+ *
187
+ * **Omitted ⇒ no `RequestedAuthnContext` element is sent at all**,
188
+ * which lets the IdP apply its own sign-on policy. That is the right
189
+ * default for nearly every deployment: a `RequestedAuthnContext` the
190
+ * IdP cannot satisfy exactly is answered with `NoAuthnContext`
191
+ * instead of a login, and an MFA policy at the IdP is a common way
192
+ * to *not* satisfy `PasswordProtectedTransport` under
193
+ * `Comparison="exact"`.
194
+ *
195
+ * Set it only when the IdP has told you which class refs it honours.
196
+ * `comparison` maps to the `Comparison` attribute and defaults to
197
+ * `"exact"`; `"minimum"` is usually the safer choice when requesting
198
+ * MFA.
199
+ *
200
+ * Requesting a context does **not** verify one was used — read
201
+ * `SamlSpProperties.authnContextClassRef` for what the IdP actually
202
+ * asserted.
203
+ */
204
+ requestedAuthnContext?: {
205
+ /** Full URNs, e.g. `urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthn`. */
206
+ classRefs: ReadonlyArray<string>
207
+ comparison?: "exact" | "minimum" | "maximum" | "better"
208
+ }
209
+ /**
210
+ * Require the `<saml:Assertion>` itself to carry a valid XML-DSig.
211
+ * **Defaults to `true` and should stay that way** — the identity,
212
+ * conditions, and audience all live inside the assertion, so signing
213
+ * it is what actually binds them.
214
+ *
215
+ * Set `false` only for an IdP that signs the outer `<Response>` and
216
+ * nothing else, and only together with `requireSignedResponse: true`.
217
+ * The schema refuses to let both be off.
218
+ */
219
+ requireSignedAssertion?: boolean
220
+ /**
221
+ * Require the outer `<samlp:Response>` to carry a valid XML-DSig.
222
+ * Default `false` — requiring it is stricter than the Okta / Entra
223
+ * default and would reject the majority of real IdPs. Enable it for
224
+ * an IdP that signs the Response, either as defence in depth
225
+ * alongside a signed assertion or (with
226
+ * `requireSignedAssertion: false`) as the only signature on offer.
227
+ */
228
+ requireSignedResponse?: boolean
155
229
  }
156
230
 
157
231
  /**
@@ -197,6 +271,27 @@ export type SamlSpProperties = {
197
271
  sessionIndex?: string
198
272
  /** Unix ms — the assertion's `AuthnInstant`. */
199
273
  authnInstant: number
274
+ /**
275
+ * Unix ms — the `AuthnStatement/@SessionNotOnOrAfter` the IdP
276
+ * asserted, when it supplied one. This is the IdP's own view of when
277
+ * its session for this user expires.
278
+ *
279
+ * The library does **not** act on it: token and session lifetimes are
280
+ * host policy, and this library owns no session. Hosts that want
281
+ * "when their IdP session ends, ours ends" should clamp their own
282
+ * session/token TTL to this value in the `success` callback.
283
+ */
284
+ sessionNotOnOrAfter?: number
285
+ /**
286
+ * The `AuthnContext/AuthnContextClassRef` the IdP actually asserted —
287
+ * i.e. how it says it authenticated the user. Absent when the
288
+ * assertion carries none.
289
+ *
290
+ * This is the value to check for step-up decisions ("was this really
291
+ * MFA?"). `SamlSpConfig.requestedAuthnContext` only *asks*; this is
292
+ * the answer, and the two can differ.
293
+ */
294
+ authnContextClassRef?: string
200
295
  raw: {
201
296
  responseXml: string
202
297
  }