@_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.
- 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 +12 -4
- 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 +20 -0
- 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/result.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 +98 -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 +35 -5
- package/src/methods/saml-sp/types.ts +97 -6
- 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 +13 -3
- package/src/types/result.ts +1 -2
- package/src/types/scim.ts +280 -0
- 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,
|
|
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
|
|
84
|
+
function parseVerifiedAssertion(
|
|
85
85
|
profile: NodeSamlProfile,
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
91
|
+
// rather than skip security checks we now promise.
|
|
92
92
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
113
|
-
|
|
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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
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))
|
|
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 =
|
|
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(
|
|
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(
|
|
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,
|
|
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. */
|
|
@@ -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 =
|
|
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).
|
|
90
|
-
//
|
|
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 =
|
|
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
|