@_mustachio/openauth 0.11.0 → 0.12.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/adapters/d1/index.js +18 -9
- package/dist/esm/adapters/d1/migrations.js +7 -2
- package/dist/esm/adapters/d1/session-store.js +39 -0
- package/dist/esm/adapters/d1/session.js +2 -2
- package/dist/esm/adapters/durable-object/index.js +4 -0
- package/dist/esm/adapters/durable-object/session-store.js +42 -0
- package/dist/esm/adapters/dynamo/index.js +24 -7
- package/dist/esm/adapters/dynamo/session-store.js +49 -0
- package/dist/esm/adapters/kms/index.js +6 -2
- package/dist/esm/adapters/kv/index.js +6 -2
- package/dist/esm/adapters/memory/index.js +24 -7
- package/dist/esm/adapters/memory/session-store.js +23 -0
- package/dist/esm/adapters/postgres/executor.js +2 -2
- package/dist/esm/adapters/postgres/index.js +33 -10
- package/dist/esm/adapters/postgres/migrations.js +8 -2
- package/dist/esm/adapters/postgres/session-store.js +42 -0
- package/dist/esm/client.js +27 -27
- package/dist/esm/domain/authorize.js +2 -2
- package/dist/esm/domain/callback.js +121 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +2 -2
- package/dist/esm/domain/dpop.js +4 -4
- package/dist/esm/domain/id-token.js +5 -5
- package/dist/esm/domain/jwt.js +4 -4
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +80 -1
- package/dist/esm/domain/par.js +2 -2
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token.js +5 -5
- package/dist/esm/error.js +8 -8
- package/dist/esm/http/cookies.js +19 -2
- package/dist/esm/http/errors.js +4 -4
- package/dist/esm/http/handlers/authorize.js +3 -19
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/metadata.js +2 -2
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/middleware/tenant.js +6 -6
- package/dist/esm/http/schemas/authorize.js +2 -2
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +33 -32
- package/dist/esm/methods/oauth2-factory.js +2 -2
- package/dist/esm/methods/password-hash.js +2 -2
- package/dist/esm/methods/providers/index.js +30 -14
- package/dist/esm/methods/saml-sp/acs.js +208 -0
- package/dist/esm/methods/saml-sp/attributes.js +75 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
- package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
- package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
- package/dist/esm/methods/saml-sp/factory.js +70 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +69 -0
- package/dist/esm/methods/saml-sp/method.js +29 -0
- package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
- package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
- package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
- package/dist/esm/methods/saml-sp/sls.js +154 -0
- package/dist/esm/methods/saml-sp/types.js +0 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/ui/forms.js +3 -3
- package/dist/esm/ui/index.js +8 -5
- package/dist/esm/ui/picker.js +2 -2
- package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
- package/dist/types/adapters/d1/session-store.d.ts +3 -0
- package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
- package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
- package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
- package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
- package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/session-store.d.ts +3 -0
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
- package/dist/types/adapters/postgres/session-store.d.ts +3 -0
- package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
- package/dist/types/domain/callback.d.ts +16 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +57 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +3 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/cookies.d.ts +9 -1
- package/dist/types/http/cookies.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts +4 -0
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/factory.d.ts +704 -0
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/index.d.ts +24 -0
- package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/method.d.ts +17 -0
- package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/sls.d.ts +4 -0
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/types.d.ts +204 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +18 -5
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +23 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +71 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +109 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/adapters/d1/migrations.ts +5 -0
- package/src/adapters/d1/session-store.ts +68 -0
- package/src/adapters/durable-object/session-store.ts +61 -0
- package/src/adapters/dynamo/session-store.ts +63 -0
- package/src/adapters/memory/session-store.ts +39 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/domain/callback.ts +215 -7
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +190 -1
- package/src/http/context.ts +6 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -21
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +75 -2
- package/src/index.ts +3 -0
- package/src/methods/saml-sp/acs.ts +399 -0
- package/src/methods/saml-sp/attributes.ts +137 -0
- package/src/methods/saml-sp/authnrequest.ts +128 -0
- package/src/methods/saml-sp/cache-provider.ts +76 -0
- package/src/methods/saml-sp/cert-rotation.ts +34 -0
- package/src/methods/saml-sp/factory.ts +118 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +204 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
- package/src/methods/saml-sp/saml-instance.ts +173 -0
- package/src/methods/saml-sp/slo-initiate.ts +177 -0
- package/src/methods/saml-sp/sls.ts +306 -0
- package/src/methods/saml-sp/types.ts +203 -0
- package/src/ports/CONSISTENCY.md +25 -0
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/session-store.ts +28 -0
- package/src/types/idp.ts +74 -0
- package/src/types/method.ts +110 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parseSamlIdpMetadata` — turn an IdP's SAML 2.0 metadata XML into the
|
|
3
|
+
* `SamlIdpConfig` shape a host stores per method instance.
|
|
4
|
+
*
|
|
5
|
+
* This is a **pure** helper exposed so a host console can offer "paste
|
|
6
|
+
* the IdP metadata XML / URL" instead of making operators hand-copy
|
|
7
|
+
* entityID, SSO URL, and signing certs. It does not import node-saml —
|
|
8
|
+
* metadata parsing needs no signature verification (the document is
|
|
9
|
+
* fetched from a trusted admin, and every assertion is independently
|
|
10
|
+
* verified at the ACS regardless of what the metadata claimed).
|
|
11
|
+
*
|
|
12
|
+
* Namespace-agnostic: elements are matched by local name so prefixes
|
|
13
|
+
* (`md:`, `ds:`, none) don't matter. Returns `Result` rather than
|
|
14
|
+
* throwing, matching the library's domain convention.
|
|
15
|
+
*/
|
|
16
|
+
import { authError } from "../../types/error"
|
|
17
|
+
import type { Result } from "../../types/result"
|
|
18
|
+
import { err, ok } from "../../types/result"
|
|
19
|
+
|
|
20
|
+
// CJS interop per the SAML house-style note.
|
|
21
|
+
import xmldom from "@xmldom/xmldom"
|
|
22
|
+
|
|
23
|
+
import type { SamlIdpConfig, SamlNameIdFormat } from "./types"
|
|
24
|
+
|
|
25
|
+
const { DOMParser } = xmldom
|
|
26
|
+
|
|
27
|
+
const HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
|
28
|
+
const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
|
29
|
+
|
|
30
|
+
const NAME_ID_FORMAT_BY_URN: Record<string, SamlNameIdFormat> = {
|
|
31
|
+
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent": "persistent",
|
|
32
|
+
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient": "transient",
|
|
33
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress": "emailAddress",
|
|
34
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified": "unspecified",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function els(root: Document | Element, localName: string): Element[] {
|
|
38
|
+
const list = root.getElementsByTagNameNS("*", localName)
|
|
39
|
+
const out: Element[] = []
|
|
40
|
+
for (let i = 0; i < list.length; i++) {
|
|
41
|
+
const n = list[i]
|
|
42
|
+
if (n) out.push(n)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pick an endpoint by binding preference (Redirect → POST → first). */
|
|
48
|
+
function pickEndpoint(endpoints: Element[]): string | undefined {
|
|
49
|
+
if (endpoints.length === 0) return undefined
|
|
50
|
+
const byBinding = (b: string) =>
|
|
51
|
+
endpoints.find((e) => e.getAttribute("Binding") === b)
|
|
52
|
+
const chosen =
|
|
53
|
+
byBinding(HTTP_REDIRECT) ?? byBinding(HTTP_POST) ?? endpoints[0]
|
|
54
|
+
const loc = chosen?.getAttribute("Location")
|
|
55
|
+
return loc && loc.length > 0 ? loc : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Normalise a base64 cert body into a PEM block (64-col lines). */
|
|
59
|
+
function toPem(rawX509: string): string {
|
|
60
|
+
const b64 = rawX509.replace(/\s+/g, "")
|
|
61
|
+
const lines = b64.match(/.{1,64}/g) ?? [b64]
|
|
62
|
+
return (
|
|
63
|
+
"-----BEGIN CERTIFICATE-----\n" +
|
|
64
|
+
lines.join("\n") +
|
|
65
|
+
"\n-----END CERTIFICATE-----"
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseSamlIdpMetadata(
|
|
70
|
+
xml: string,
|
|
71
|
+
): Result<SamlIdpConfig> {
|
|
72
|
+
if (typeof xml !== "string" || xml.trim().length === 0) {
|
|
73
|
+
return err(authError.invalidRequest("metadata XML is empty"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let doc: Document
|
|
77
|
+
try {
|
|
78
|
+
doc = new DOMParser().parseFromString(
|
|
79
|
+
xml,
|
|
80
|
+
"text/xml",
|
|
81
|
+
) as unknown as Document
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return err(
|
|
84
|
+
authError.invalidRequest(
|
|
85
|
+
`metadata XML is not well-formed: ${
|
|
86
|
+
e instanceof Error ? e.message : String(e)
|
|
87
|
+
}`,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
if (!doc?.documentElement) {
|
|
92
|
+
return err(authError.invalidRequest("metadata XML has no root element"))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const entityDescriptor = els(doc, "EntityDescriptor")[0]
|
|
96
|
+
if (!entityDescriptor) {
|
|
97
|
+
return err(
|
|
98
|
+
authError.invalidRequest("metadata has no <EntityDescriptor>"),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
const entityId = entityDescriptor.getAttribute("entityID")
|
|
102
|
+
if (!entityId) {
|
|
103
|
+
return err(
|
|
104
|
+
authError.invalidRequest("EntityDescriptor has no entityID"),
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const idp = els(entityDescriptor, "IDPSSODescriptor")[0]
|
|
109
|
+
if (!idp) {
|
|
110
|
+
return err(
|
|
111
|
+
authError.invalidRequest(
|
|
112
|
+
"not an IdP metadata document (no <IDPSSODescriptor>) — this looks " +
|
|
113
|
+
"like SP metadata",
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ssoUrl = pickEndpoint(els(idp, "SingleSignOnService"))
|
|
119
|
+
if (!ssoUrl) {
|
|
120
|
+
return err(
|
|
121
|
+
authError.invalidRequest(
|
|
122
|
+
"IDPSSODescriptor has no usable <SingleSignOnService> Location",
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
const sloUrl = pickEndpoint(els(idp, "SingleLogoutService"))
|
|
127
|
+
|
|
128
|
+
let nameIdFormat: SamlNameIdFormat | undefined
|
|
129
|
+
for (const f of els(idp, "NameIDFormat")) {
|
|
130
|
+
const mapped = NAME_ID_FORMAT_BY_URN[(f.textContent ?? "").trim()]
|
|
131
|
+
if (mapped) {
|
|
132
|
+
nameIdFormat = mapped
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Signing certs: a KeyDescriptor with use="signing", or with no `use`
|
|
138
|
+
// (per SAML metadata spec an absent `use` means the key is valid for
|
|
139
|
+
// both signing and encryption).
|
|
140
|
+
const signingCerts: { pem: string }[] = []
|
|
141
|
+
for (const kd of els(idp, "KeyDescriptor")) {
|
|
142
|
+
const use = kd.getAttribute("use")
|
|
143
|
+
if (use && use !== "signing") continue
|
|
144
|
+
for (const cert of els(kd, "X509Certificate")) {
|
|
145
|
+
const body = (cert.textContent ?? "").trim()
|
|
146
|
+
if (body.length > 0) signingCerts.push({ pem: toPem(body) })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (signingCerts.length === 0) {
|
|
150
|
+
return err(
|
|
151
|
+
authError.invalidRequest(
|
|
152
|
+
"IDPSSODescriptor has no signing <X509Certificate>",
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return ok({
|
|
158
|
+
entityId,
|
|
159
|
+
ssoUrl,
|
|
160
|
+
...(sloUrl !== undefined ? { sloUrl } : {}),
|
|
161
|
+
...(nameIdFormat !== undefined ? { nameIdFormat } : {}),
|
|
162
|
+
signingCerts,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Construct a `@node-saml/node-saml` `SAML` instance from validated
|
|
3
|
+
* `SamlSpConfig` plus the per-request binding context (SP entityID +
|
|
4
|
+
* ACS URL, both derived at dispatch time per SAML-AD5).
|
|
5
|
+
*
|
|
6
|
+
* CJS interop: node-saml is CommonJS-only. Per the SAML house-style
|
|
7
|
+
* note, default-import then destructure rather than relying on the
|
|
8
|
+
* named-export heuristic.
|
|
9
|
+
*/
|
|
10
|
+
import nodeSaml from "@node-saml/node-saml"
|
|
11
|
+
|
|
12
|
+
import type { MethodScratch } from "../../types/method"
|
|
13
|
+
|
|
14
|
+
import { methodScratchCacheProvider } from "./cache-provider"
|
|
15
|
+
import { selectActiveCertPems } from "./cert-rotation"
|
|
16
|
+
import type { SamlNameIdFormat, SamlSpConfig } from "./types"
|
|
17
|
+
|
|
18
|
+
const { SAML, ValidateInResponseTo } = nodeSaml
|
|
19
|
+
type SamlInstance = InstanceType<typeof SAML>
|
|
20
|
+
|
|
21
|
+
/** Standard SAML 2.0 NameID format URNs. */
|
|
22
|
+
export const NAME_ID_FORMAT_URN: Record<SamlNameIdFormat, string> = {
|
|
23
|
+
persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
|
24
|
+
transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
|
25
|
+
emailAddress: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
26
|
+
unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Outstanding-request-id cache TTL. Must comfortably outlast the
|
|
31
|
+
* slowest realistic IdP login screen; the flow record expires on its
|
|
32
|
+
* own shorter clock, so this is just the upper bound on how long an
|
|
33
|
+
* `InResponseTo` stays accepted.
|
|
34
|
+
*/
|
|
35
|
+
const IN_RESPONSE_TO_TTL_MS = 60 * 60 * 1000
|
|
36
|
+
|
|
37
|
+
export type SamlBindingContext = {
|
|
38
|
+
/** SP entityID — `<issuerUrl>/<tenantId>/<methodId>` (SAML-AD5). */
|
|
39
|
+
spEntityId: string
|
|
40
|
+
/** ACS URL — the framework's `/cb/<methodId>` universal callback. */
|
|
41
|
+
acsUrl: string
|
|
42
|
+
/** Per-request scratch backing the `InResponseTo` cache. */
|
|
43
|
+
scratch: MethodScratch
|
|
44
|
+
/**
|
|
45
|
+
* IdP-initiated mode. An unsolicited Response has no `InResponseTo`
|
|
46
|
+
* (there was no AuthnRequest), so `validateInResponseTo` must be
|
|
47
|
+
* `ifPresent` rather than `always` — otherwise node-saml throws
|
|
48
|
+
* "InResponseTo is missing". Signature / Issuer / Audience /
|
|
49
|
+
* Conditions are still fully enforced; assertion-ID replay dedup is
|
|
50
|
+
* layered on top by the caller (node-saml does not dedup unsolicited
|
|
51
|
+
* assertion IDs). Default `false` ⇒ SP-initiated, `always`
|
|
52
|
+
* (unchanged).
|
|
53
|
+
*/
|
|
54
|
+
idpInitiated?: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Logout-binding instance (the `/sls` + `/logout` paths). node-saml's
|
|
57
|
+
* `InResponseTo` machinery only reads the attribute off a `Response`
|
|
58
|
+
* root, never a `LogoutResponse`, so with the default `always` it
|
|
59
|
+
* throws "InResponseTo is missing" on every inbound `LogoutResponse`.
|
|
60
|
+
* Front-channel logout correlation is out of that scope — we
|
|
61
|
+
* replay-dedup the inbound `LogoutRequest @ID` via `methodScratch`
|
|
62
|
+
* instead — so logout instances use `never`. Overrides `idpInitiated`.
|
|
63
|
+
*/
|
|
64
|
+
logout?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* Per-connection SP signing material (SAML-AD: O3 — decoupled from
|
|
67
|
+
* the OIDC `KeyStore`; the IdP pins this cert, rotation is an
|
|
68
|
+
* IdP-coordination event). Present ⇒ node-saml signs the outbound
|
|
69
|
+
* `AuthnRequest` (HTTP-Redirect binding) **and** outbound logout
|
|
70
|
+
* messages (`LogoutRequest` / `LogoutResponse`) with it.
|
|
71
|
+
*/
|
|
72
|
+
signing?: {
|
|
73
|
+
privateKeyPem: string
|
|
74
|
+
certPem: string
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* IdP Single Logout endpoint (`SamlSpConfig.idp.sloUrl`). Destination
|
|
78
|
+
* for SP-emitted `LogoutResponse` (front-channel SLO, Phase 3) and
|
|
79
|
+
* SP-initiated `LogoutRequest`. Only needed on the `/sls` path; the
|
|
80
|
+
* SSO paths leave it unset.
|
|
81
|
+
*/
|
|
82
|
+
logoutUrl?: string
|
|
83
|
+
/**
|
|
84
|
+
* SP decryption private key PEM (`SamlSpConfig.decryptionKey`),
|
|
85
|
+
* passed through to node-saml's `decryptionPvk`. Present ⇒ node-saml
|
|
86
|
+
* decrypts `<saml:EncryptedAssertion>` (the decrypted assertion's
|
|
87
|
+
* XML-DSig is still fully enforced). Set only when the connection
|
|
88
|
+
* opted into `allowEncryptedAssertions`; absent ⇒ an encrypted
|
|
89
|
+
* assertion is rejected.
|
|
90
|
+
*/
|
|
91
|
+
decryptionPvk?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Derive the per-instance SP entityID. Stable across deploys for a
|
|
96
|
+
* given `(issuerUrl, tenantId, methodId)` triple so IdP-side trust
|
|
97
|
+
* config doesn't churn.
|
|
98
|
+
*/
|
|
99
|
+
export function deriveSpEntityId(
|
|
100
|
+
issuerUrl: string,
|
|
101
|
+
tenantId: string,
|
|
102
|
+
methodId: string,
|
|
103
|
+
): string {
|
|
104
|
+
const base = issuerUrl.endsWith("/") ? issuerUrl.slice(0, -1) : issuerUrl
|
|
105
|
+
return `${base}/${tenantId}/${methodId}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildSamlInstance(
|
|
109
|
+
config: SamlSpConfig,
|
|
110
|
+
binding: SamlBindingContext,
|
|
111
|
+
nowMs: number,
|
|
112
|
+
): SamlInstance {
|
|
113
|
+
const activeCerts = selectActiveCertPems(config.idp.signingCerts, nowMs)
|
|
114
|
+
if (activeCerts.length === 0) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"saml-sp: no IdP signing certificate is currently within its " +
|
|
117
|
+
"validity window — check SamlSpConfig.idp.signingCerts notBefore/notAfter.",
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const identifierFormat =
|
|
122
|
+
config.idp.nameIdFormat !== undefined
|
|
123
|
+
? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
|
|
124
|
+
: null
|
|
125
|
+
|
|
126
|
+
return new SAML({
|
|
127
|
+
// MandatorySamlOptions
|
|
128
|
+
idpCert: activeCerts,
|
|
129
|
+
issuer: binding.spEntityId,
|
|
130
|
+
callbackUrl: binding.acsUrl,
|
|
131
|
+
// IdP endpoints
|
|
132
|
+
entryPoint: config.idp.ssoUrl,
|
|
133
|
+
idpIssuer: config.idp.entityId,
|
|
134
|
+
audience: binding.spEntityId,
|
|
135
|
+
identifierFormat,
|
|
136
|
+
// Security posture. A signed *assertion* is mandatory (identity +
|
|
137
|
+
// conditions + audience all live in the signed bytes); requiring
|
|
138
|
+
// the outer Response to also be signed is stricter than the
|
|
139
|
+
// Okta/Entra default and would reject the majority of real IdPs,
|
|
140
|
+
// so it is not required here. SP-initiated requires an outstanding
|
|
141
|
+
// InResponseTo (`always`); IdP-initiated is unsolicited so it must
|
|
142
|
+
// be `ifPresent` — the caller layers explicit assertion-ID replay
|
|
143
|
+
// dedup on top for that mode.
|
|
144
|
+
validateInResponseTo: binding.logout
|
|
145
|
+
? ValidateInResponseTo.never
|
|
146
|
+
: binding.idpInitiated
|
|
147
|
+
? ValidateInResponseTo.ifPresent
|
|
148
|
+
: ValidateInResponseTo.always,
|
|
149
|
+
wantAssertionsSigned: true,
|
|
150
|
+
wantAuthnResponseSigned: false,
|
|
151
|
+
acceptedClockSkewMs: (config.clockSkewSeconds ?? 60) * 1000,
|
|
152
|
+
cacheProvider: methodScratchCacheProvider(
|
|
153
|
+
binding.scratch,
|
|
154
|
+
IN_RESPONSE_TO_TTL_MS,
|
|
155
|
+
),
|
|
156
|
+
// O3: per-connection SP signing key (opt-in). node-saml signs the
|
|
157
|
+
// HTTP-Redirect AuthnRequest / logout messages with this PEM keypair.
|
|
158
|
+
...(binding.signing
|
|
159
|
+
? {
|
|
160
|
+
privateKey: binding.signing.privateKeyPem,
|
|
161
|
+
publicCert: binding.signing.certPem,
|
|
162
|
+
signatureAlgorithm: "sha256" as const,
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
165
|
+
// SP-emitted LogoutResponse / LogoutRequest destination (the IdP's
|
|
166
|
+
// SLO endpoint). node-saml's logout URL builder reads `logoutUrl`.
|
|
167
|
+
...(binding.logoutUrl ? { logoutUrl: binding.logoutUrl } : {}),
|
|
168
|
+
// Encrypted-assertion decryption (opt-in per connection).
|
|
169
|
+
...(binding.decryptionPvk
|
|
170
|
+
? { decryptionPvk: binding.decryptionPvk }
|
|
171
|
+
: {}),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP-initiated Single Logout — the outbound half.
|
|
3
|
+
*
|
|
4
|
+
* `POST /m/<methodId>/logout` (public, gated on `idp.sloUrl` like
|
|
5
|
+
* `/sls`). The **host** invokes this from its own logout UX for the
|
|
6
|
+
* **already-authenticated** subject, supplying the subject's SAML
|
|
7
|
+
* `NameID` (and, ideally, `SessionIndex`) — the values it received in
|
|
8
|
+
* `SamlSpProperties` at login. The library does not persist the
|
|
9
|
+
* NameID↔subject↔session mapping (the host owns it; see
|
|
10
|
+
* `ARCHITECTURE.md` §"onLogout"), so the host is the only party that
|
|
11
|
+
* can name who is logging out.
|
|
12
|
+
*
|
|
13
|
+
* Host contract / security:
|
|
14
|
+
* - This route only **propagates** logout to the upstream IdP. It
|
|
15
|
+
* does **not** revoke library tokens — OIDC session/token
|
|
16
|
+
* termination is `/end_session`'s job and is deliberately *not*
|
|
17
|
+
* auto-bridged. A complete host logout flow calls both.
|
|
18
|
+
* - It is `POST` (not `GET`) to keep a signed `LogoutRequest` off
|
|
19
|
+
* drive-by navigations / prefetch. It is still anonymous at the
|
|
20
|
+
* library boundary: the host MUST only render/trigger it for the
|
|
21
|
+
* authenticated subject, behind the host's own CSRF protection,
|
|
22
|
+
* with that subject's own `NameID`. A forced-logout via CSRF is a
|
|
23
|
+
* DoS-class risk the host owns (it owns the session); the library
|
|
24
|
+
* cannot authenticate the caller without owning a session it
|
|
25
|
+
* deliberately does not.
|
|
26
|
+
*
|
|
27
|
+
* The IdP processes the `LogoutRequest` and returns a `LogoutResponse`
|
|
28
|
+
* to `/sls` (the response leg — see `sls.ts`).
|
|
29
|
+
*/
|
|
30
|
+
import { authError } from "../../types/error"
|
|
31
|
+
import type { MethodContext, MethodResult } from "../../types/method"
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
buildSamlInstance,
|
|
35
|
+
deriveSpEntityId,
|
|
36
|
+
NAME_ID_FORMAT_URN,
|
|
37
|
+
} from "./saml-instance"
|
|
38
|
+
import type {
|
|
39
|
+
SamlNameIdFormat,
|
|
40
|
+
SamlSpConfig,
|
|
41
|
+
SamlSpProperties,
|
|
42
|
+
SamlSpState,
|
|
43
|
+
} from "./types"
|
|
44
|
+
|
|
45
|
+
export async function initiateSpLogout(
|
|
46
|
+
ctx: MethodContext<SamlSpState>,
|
|
47
|
+
methodId: string,
|
|
48
|
+
config: SamlSpConfig,
|
|
49
|
+
): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
|
|
50
|
+
if (!ctx.dispatch) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "error",
|
|
53
|
+
error: authError.internalError(
|
|
54
|
+
"saml-sp: /logout dispatched without issuer context (ctx.dispatch is null)",
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!config.idp.sloUrl) {
|
|
59
|
+
// Unreachable: /logout is only public when sloUrl is set.
|
|
60
|
+
return {
|
|
61
|
+
kind: "error",
|
|
62
|
+
error: authError.internalError(
|
|
63
|
+
"saml-sp: /logout reached with no idp.sloUrl configured",
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let nameId: string | null
|
|
69
|
+
let sessionIndex: string | null
|
|
70
|
+
let relayState: string | null
|
|
71
|
+
let nameIdFormatParam: string | null
|
|
72
|
+
try {
|
|
73
|
+
const form = new URLSearchParams(await ctx.request.text())
|
|
74
|
+
nameId = form.get("nameId")
|
|
75
|
+
sessionIndex = form.get("sessionIndex")
|
|
76
|
+
relayState = form.get("relayState")
|
|
77
|
+
nameIdFormatParam = form.get("nameIdFormat")
|
|
78
|
+
} catch {
|
|
79
|
+
return {
|
|
80
|
+
kind: "error",
|
|
81
|
+
error: authError.internalError("saml-sp: /logout could not read POST body"),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!nameId) {
|
|
86
|
+
// The host must supply the subject's SAML NameID — the library
|
|
87
|
+
// cannot derive it (it never persisted the mapping).
|
|
88
|
+
return { kind: "denied", reason: "missing nameId" }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// NameID format: explicit param wins; else the connection's
|
|
92
|
+
// configured format; else persistent (the SSO default). The param is
|
|
93
|
+
// a friendly key (same vocabulary as `config.idp.nameIdFormat`), not
|
|
94
|
+
// a raw URN — validate it rather than passing an arbitrary
|
|
95
|
+
// host-supplied string straight into the LogoutRequest, where a
|
|
96
|
+
// typo'd format silently produces a cryptic IdP-side rejection.
|
|
97
|
+
if (
|
|
98
|
+
nameIdFormatParam !== null &&
|
|
99
|
+
!(nameIdFormatParam in NAME_ID_FORMAT_URN)
|
|
100
|
+
) {
|
|
101
|
+
return {
|
|
102
|
+
kind: "denied",
|
|
103
|
+
reason:
|
|
104
|
+
`unrecognized nameIdFormat "${nameIdFormatParam}" — expected one ` +
|
|
105
|
+
`of: ${Object.keys(NAME_ID_FORMAT_URN).join(", ")}`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const nameIDFormat =
|
|
109
|
+
nameIdFormatParam !== null
|
|
110
|
+
? NAME_ID_FORMAT_URN[nameIdFormatParam as SamlNameIdFormat]
|
|
111
|
+
: config.idp.nameIdFormat !== undefined
|
|
112
|
+
? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
|
|
113
|
+
: NAME_ID_FORMAT_URN.persistent
|
|
114
|
+
|
|
115
|
+
const spEntityId = deriveSpEntityId(
|
|
116
|
+
ctx.dispatch.issuerUrl,
|
|
117
|
+
ctx.tenant.id,
|
|
118
|
+
methodId,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
let redirectUrl: string
|
|
122
|
+
try {
|
|
123
|
+
const saml = buildSamlInstance(
|
|
124
|
+
config,
|
|
125
|
+
{
|
|
126
|
+
spEntityId,
|
|
127
|
+
acsUrl: ctx.dispatch.callbackUrl,
|
|
128
|
+
scratch: ctx.methodScratch,
|
|
129
|
+
logoutUrl: config.idp.sloUrl,
|
|
130
|
+
logout: true,
|
|
131
|
+
// Sign the LogoutRequest whenever a per-connection SP key is
|
|
132
|
+
// configured — most IdPs require a signed SP-initiated logout.
|
|
133
|
+
...(config.signingKey
|
|
134
|
+
? {
|
|
135
|
+
signing: {
|
|
136
|
+
privateKeyPem: config.signingKey.privateKeyPem,
|
|
137
|
+
certPem: config.signingKey.certPem,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
},
|
|
142
|
+
Date.now(),
|
|
143
|
+
)
|
|
144
|
+
redirectUrl = await saml.getLogoutUrlAsync(
|
|
145
|
+
// node-saml's Profile shape; structurally compatible for request
|
|
146
|
+
// generation (it reads nameID / nameIDFormat / sessionIndex).
|
|
147
|
+
{
|
|
148
|
+
nameID: nameId,
|
|
149
|
+
nameIDFormat: nameIDFormat,
|
|
150
|
+
...(sessionIndex !== null ? { sessionIndex } : {}),
|
|
151
|
+
} as never,
|
|
152
|
+
relayState ?? "",
|
|
153
|
+
{},
|
|
154
|
+
)
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return {
|
|
157
|
+
kind: "error",
|
|
158
|
+
error: authError.internalError(
|
|
159
|
+
`saml-sp: failed to build LogoutRequest: ${
|
|
160
|
+
e instanceof Error ? e.message : String(e)
|
|
161
|
+
}`,
|
|
162
|
+
e,
|
|
163
|
+
),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Pure protocol propagation — no `logout` side effect (the host
|
|
168
|
+
// already tore down its session and, separately, calls /end_session
|
|
169
|
+
// for OIDC token revocation).
|
|
170
|
+
return {
|
|
171
|
+
kind: "challenge",
|
|
172
|
+
response: new Response(null, {
|
|
173
|
+
status: 302,
|
|
174
|
+
headers: { location: redirectUrl },
|
|
175
|
+
}),
|
|
176
|
+
}
|
|
177
|
+
}
|