@_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.
- package/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +25 -0
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/methods/saml-sp/acs.js +44 -20
- package/dist/esm/methods/saml-sp/attributes.js +2 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
- package/dist/esm/methods/saml-sp/factory.js +12 -1
- package/dist/esm/methods/saml-sp/metadata.js +5 -3
- package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
- package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
- package/dist/esm/methods/saml-sp/sls.js +2 -2
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/scim.js +0 -0
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +3 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/factory.d.ts +217 -1
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/types.d.ts +95 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +12 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +3 -0
- package/src/http/handlers/authorize.ts +0 -1
- package/src/http/handlers/method-route.ts +1 -4
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +20 -0
- package/src/methods/saml-sp/acs.ts +99 -49
- package/src/methods/saml-sp/attributes.ts +12 -3
- package/src/methods/saml-sp/authnrequest.ts +3 -2
- package/src/methods/saml-sp/factory.ts +24 -3
- package/src/methods/saml-sp/metadata.ts +19 -8
- package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
- package/src/methods/saml-sp/saml-instance.ts +42 -5
- package/src/methods/saml-sp/slo-initiate.ts +6 -3
- package/src/methods/saml-sp/sls.ts +3 -2
- package/src/methods/saml-sp/types.ts +95 -0
- package/src/ports/CONSISTENCY.md +28 -25
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +1 -5
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +12 -0
- package/src/types/scim.ts +280 -0
- 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,
|
|
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
|
|
84
|
+
function parseVerifiedAssertion(
|
|
85
85
|
profile: NodeSamlProfile,
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
92
|
+
// rather than skip security checks we now promise.
|
|
92
93
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
|
|
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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
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))
|
|
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 =
|
|
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(
|
|
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(
|
|
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,
|
|
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 =
|
|
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) =>
|
|
108
|
+
(c) =>
|
|
109
|
+
(c.requireSignedAssertion ?? true) || c.requireSignedResponse === true,
|
|
90
110
|
{
|
|
91
111
|
message:
|
|
92
|
-
"
|
|
93
|
-
|
|
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
|
-
* (`
|
|
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
|
|
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 {
|
|
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="
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
}
|