@_mustachio/openauth 0.11.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/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/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/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/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +31 -6
- package/dist/esm/http/router.js +3 -0
- 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 +34 -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 +232 -0
- package/dist/esm/methods/saml-sp/attributes.js +77 -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 +81 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +71 -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 +59 -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/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/types/scim.js +0 -0
- 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/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 +6 -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/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 +3 -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 +45 -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 +920 -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 +50 -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 +93 -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 +299 -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/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 +23 -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 +83 -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/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 +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/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 +9 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -22
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +72 -2
- 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 +23 -0
- package/src/methods/saml-sp/acs.ts +449 -0
- package/src/methods/saml-sp/attributes.ts +146 -0
- package/src/methods/saml-sp/authnrequest.ts +129 -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 +139 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +215 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
- package/src/methods/saml-sp/saml-instance.ts +210 -0
- package/src/methods/saml-sp/slo-initiate.ts +180 -0
- package/src/methods/saml-sp/sls.ts +307 -0
- package/src/methods/saml-sp/types.ts +298 -0
- package/src/ports/CONSISTENCY.md +52 -24
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +24 -0
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +86 -0
- package/src/types/method.ts +110 -0
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +10 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP metadata XML — the outbound half of metadata exchange
|
|
3
|
+
* (`parseSamlIdpMetadata` is the inbound half).
|
|
4
|
+
*
|
|
5
|
+
* Served anonymously at `GET /m/<methodId>/metadata` via the method's
|
|
6
|
+
* `publicRoutes` allowlist (no flow cookie). An enterprise IdP admin
|
|
7
|
+
* imports this document to register us as an SP.
|
|
8
|
+
*
|
|
9
|
+
* **Conformance invariant:** the `entityID` and ACS `Location` are
|
|
10
|
+
* derived from the *same* inputs the live AuthnRequest / ACS path uses
|
|
11
|
+
* (`resolveSpEntityId(config, issuerUrl, tenantId, methodId)` and
|
|
12
|
+
* `ctx.dispatch.callbackUrl`). The metadata therefore describes exactly
|
|
13
|
+
* what the runtime accepts — never an independently re-specified value
|
|
14
|
+
* that could drift. `metadata.test.ts` asserts this equality against
|
|
15
|
+
* `buildAuthnRequestRedirect` so drift fails CI.
|
|
16
|
+
*
|
|
17
|
+
* `KeyDescriptor` is emitted only when we actually sign AuthnRequests
|
|
18
|
+
* (advertising a cert we cannot use would be the bug);
|
|
19
|
+
* `SingleLogoutService` only when an IdP SLO endpoint is configured
|
|
20
|
+
* and the `/sls` route is therefore served (advertising an endpoint we
|
|
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.
|
|
24
|
+
*/
|
|
25
|
+
import { authError } from "../../types/error"
|
|
26
|
+
import type { MethodContext, MethodResult } from "../../types/method"
|
|
27
|
+
|
|
28
|
+
import { resolveSpEntityId } from "./saml-instance"
|
|
29
|
+
import type {
|
|
30
|
+
SamlNameIdFormat,
|
|
31
|
+
SamlSpConfig,
|
|
32
|
+
SamlSpProperties,
|
|
33
|
+
SamlSpState,
|
|
34
|
+
} from "./types"
|
|
35
|
+
|
|
36
|
+
const NAME_ID_FORMAT_URN: Record<SamlNameIdFormat, string> = {
|
|
37
|
+
persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
|
38
|
+
transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
|
39
|
+
emailAddress: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
40
|
+
unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
|
44
|
+
const HTTP_REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
|
45
|
+
|
|
46
|
+
/** Minimal XML attribute/text escaping for URL-shaped values. */
|
|
47
|
+
function xmlEscape(s: string): string {
|
|
48
|
+
return s
|
|
49
|
+
.replace(/&/g, "&")
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/"/g, """)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SpMetadataInput = {
|
|
56
|
+
spEntityId: string
|
|
57
|
+
acsUrl: string
|
|
58
|
+
/**
|
|
59
|
+
* The SP's own Single Logout Service URL (`/m/<methodId>/sls`).
|
|
60
|
+
* Present ⇒ front-channel SLO is served, so the metadata advertises
|
|
61
|
+
* `SingleLogoutService` for both bindings we accept. Same
|
|
62
|
+
* advertise-only-what-we-serve invariant as the ACS / signing cert.
|
|
63
|
+
*/
|
|
64
|
+
slsUrl?: string
|
|
65
|
+
nameIdFormat?: SamlNameIdFormat
|
|
66
|
+
/**
|
|
67
|
+
* SP signing cert (PEM). Present ⇒ we sign AuthnRequests, so the
|
|
68
|
+
* metadata advertises `AuthnRequestsSigned="true"` and a
|
|
69
|
+
* `KeyDescriptor use="signing"`. Keeping this in lockstep with the
|
|
70
|
+
* runtime (the same `config.signingKey.certPem` that actually signs)
|
|
71
|
+
* is the same anti-drift invariant as the entityID/ACS.
|
|
72
|
+
*/
|
|
73
|
+
signingCertPem?: string
|
|
74
|
+
/**
|
|
75
|
+
* SP encryption cert (PEM). Present ⇒ the connection accepts
|
|
76
|
+
* encrypted assertions, so the metadata advertises a
|
|
77
|
+
* `KeyDescriptor use="encryption"` (the cert the IdP encrypts to —
|
|
78
|
+
* `config.decryptionKey.certPem`). Same anti-drift / advertise-only-
|
|
79
|
+
* what-we-serve invariant as the signing cert.
|
|
80
|
+
*/
|
|
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
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** PEM cert body → bare base64 (SAML metadata X509Certificate form). */
|
|
93
|
+
function certBody(pem: string): string {
|
|
94
|
+
return pem
|
|
95
|
+
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
|
96
|
+
.replace(/-----END CERTIFICATE-----/g, "")
|
|
97
|
+
.replace(/\s+/g, "")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pure SP metadata builder. Mirrors `parseSamlIdpMetadata`'s purity so
|
|
102
|
+
* it is trivially unit-testable and reusable.
|
|
103
|
+
*/
|
|
104
|
+
export function buildSpMetadataXml(input: SpMetadataInput): string {
|
|
105
|
+
const entityId = xmlEscape(input.spEntityId)
|
|
106
|
+
const acs = xmlEscape(input.acsUrl)
|
|
107
|
+
const signed = input.signingCertPem !== undefined
|
|
108
|
+
const wantAssertionsSigned = input.wantAssertionsSigned ?? true
|
|
109
|
+
const nameIdLine =
|
|
110
|
+
input.nameIdFormat !== undefined
|
|
111
|
+
? `\n <md:NameIDFormat>${NAME_ID_FORMAT_URN[input.nameIdFormat]}</md:NameIDFormat>`
|
|
112
|
+
: ""
|
|
113
|
+
const x509 = (cert: string) =>
|
|
114
|
+
`<ds:KeyInfo><ds:X509Data><ds:X509Certificate>` +
|
|
115
|
+
certBody(cert) +
|
|
116
|
+
`</ds:X509Certificate></ds:X509Data></ds:KeyInfo>`
|
|
117
|
+
const keyDescriptor =
|
|
118
|
+
(signed
|
|
119
|
+
? `\n <md:KeyDescriptor use="signing">` +
|
|
120
|
+
x509(input.signingCertPem as string) +
|
|
121
|
+
`</md:KeyDescriptor>`
|
|
122
|
+
: "") +
|
|
123
|
+
(input.encryptionCertPem !== undefined
|
|
124
|
+
? `\n <md:KeyDescriptor use="encryption">` +
|
|
125
|
+
x509(input.encryptionCertPem) +
|
|
126
|
+
`</md:KeyDescriptor>`
|
|
127
|
+
: "")
|
|
128
|
+
// Schema order: KeyDescriptor → SingleLogoutService → NameIDFormat →
|
|
129
|
+
// AssertionConsumerService. Advertise both bindings we accept at /sls.
|
|
130
|
+
const sls =
|
|
131
|
+
input.slsUrl !== undefined
|
|
132
|
+
? `\n <md:SingleLogoutService ` +
|
|
133
|
+
`Binding="${HTTP_REDIRECT}" Location="${xmlEscape(input.slsUrl)}"/>` +
|
|
134
|
+
`\n <md:SingleLogoutService ` +
|
|
135
|
+
`Binding="${HTTP_POST}" Location="${xmlEscape(input.slsUrl)}"/>`
|
|
136
|
+
: ""
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
140
|
+
`<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ` +
|
|
141
|
+
`xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ` +
|
|
142
|
+
`entityID="${entityId}">\n` +
|
|
143
|
+
` <md:SPSSODescriptor AuthnRequestsSigned="${signed}" ` +
|
|
144
|
+
`WantAssertionsSigned="${wantAssertionsSigned}" ` +
|
|
145
|
+
`protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">` +
|
|
146
|
+
keyDescriptor +
|
|
147
|
+
sls +
|
|
148
|
+
nameIdLine +
|
|
149
|
+
`\n <md:AssertionConsumerService ` +
|
|
150
|
+
`Binding="${HTTP_POST}" Location="${acs}" index="0" isDefault="true"/>` +
|
|
151
|
+
`\n </md:SPSSODescriptor>\n` +
|
|
152
|
+
`</md:EntityDescriptor>\n`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `GET /metadata` route handler. Anonymous (declared in the method's
|
|
158
|
+
* `publicRoutes`): `ctx.flow` / `ctx.methodState` are null; everything
|
|
159
|
+
* comes from `ctx.tenant` + `ctx.dispatch` + captured config.
|
|
160
|
+
*/
|
|
161
|
+
export async function buildSpMetadata(
|
|
162
|
+
ctx: MethodContext<SamlSpState>,
|
|
163
|
+
methodId: string,
|
|
164
|
+
config: SamlSpConfig,
|
|
165
|
+
): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
|
|
166
|
+
if (!ctx.dispatch) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "error",
|
|
169
|
+
error: authError.internalError(
|
|
170
|
+
"saml-sp: metadata route dispatched without issuer context " +
|
|
171
|
+
"(ctx.dispatch is null)",
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const spEntityId = resolveSpEntityId(
|
|
177
|
+
config,
|
|
178
|
+
ctx.dispatch.issuerUrl,
|
|
179
|
+
ctx.tenant.id,
|
|
180
|
+
methodId,
|
|
181
|
+
)
|
|
182
|
+
// SP SLS URL — same host as the ACS callback, at the public method
|
|
183
|
+
// mount `/m/<methodId>/sls`. Only advertised when SLO is actually
|
|
184
|
+
// served (idp.sloUrl set ⇒ /sls is in publicRoutes). Derived from the
|
|
185
|
+
// same dispatch input as the ACS so it cannot drift.
|
|
186
|
+
const cb = new URL(ctx.dispatch.callbackUrl)
|
|
187
|
+
const slsUrl = `${cb.protocol}//${cb.host}/m/${methodId}/sls`
|
|
188
|
+
const xml = buildSpMetadataXml({
|
|
189
|
+
spEntityId,
|
|
190
|
+
acsUrl: ctx.dispatch.callbackUrl,
|
|
191
|
+
...(config.idp.sloUrl ? { slsUrl } : {}),
|
|
192
|
+
...(config.idp.nameIdFormat !== undefined
|
|
193
|
+
? { nameIdFormat: config.idp.nameIdFormat }
|
|
194
|
+
: {}),
|
|
195
|
+
wantAssertionsSigned: config.requireSignedAssertion ?? true,
|
|
196
|
+
// Truthful: advertise signing iff we actually sign AuthnRequests.
|
|
197
|
+
...(config.signAuthnRequest && config.signingKey
|
|
198
|
+
? { signingCertPem: config.signingKey.certPem }
|
|
199
|
+
: {}),
|
|
200
|
+
// Truthful: advertise an encryption cert iff we accept (and can
|
|
201
|
+
// decrypt) encrypted assertions.
|
|
202
|
+
...(config.allowEncryptedAssertions && config.decryptionKey
|
|
203
|
+
? { encryptionCertPem: config.decryptionKey.certPem }
|
|
204
|
+
: {}),
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
kind: "challenge",
|
|
209
|
+
response: new Response(xml, {
|
|
210
|
+
status: 200,
|
|
211
|
+
headers: { "content-type": "application/samlmetadata+xml" },
|
|
212
|
+
}),
|
|
213
|
+
cache: { sMaxAge: 300 },
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildSamlSpMethod` — assembles the `AuthMethod` for a SAML SP
|
|
3
|
+
* instance. Mirrors `buildOauth2Method`: data + handler functions, no
|
|
4
|
+
* framework imports beyond `types/`.
|
|
5
|
+
*
|
|
6
|
+
* Routes:
|
|
7
|
+
* - `GET /authorize` → SP-initiated AuthnRequest redirect (implemented).
|
|
8
|
+
* - `GET /callback` → ACS verification gauntlet (next increment).
|
|
9
|
+
*
|
|
10
|
+
* The framework's universal callback (`/cb/<methodId>`, both GET and
|
|
11
|
+
* POST) always dispatches the `"GET /callback"` route key, so the ACS
|
|
12
|
+
* lives there rather than at a bespoke `/acs` sub-path.
|
|
13
|
+
*/
|
|
14
|
+
import type { AuthMethod, MethodContext } from "../../types/method"
|
|
15
|
+
|
|
16
|
+
import { consumeAssertion } from "./acs"
|
|
17
|
+
import { buildAuthnRequestRedirect } from "./authnrequest"
|
|
18
|
+
import { buildSpMetadata } from "./metadata"
|
|
19
|
+
import { consumeSls } from "./sls"
|
|
20
|
+
import { initiateSpLogout } from "./slo-initiate"
|
|
21
|
+
import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
|
|
22
|
+
|
|
23
|
+
export function buildSamlSpMethod(
|
|
24
|
+
id: string,
|
|
25
|
+
kind: string,
|
|
26
|
+
config: SamlSpConfig,
|
|
27
|
+
): AuthMethod<SamlSpProperties, SamlSpState> {
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
kind,
|
|
31
|
+
type: "custom",
|
|
32
|
+
routes: {
|
|
33
|
+
"GET /authorize": (ctx: MethodContext<SamlSpState>) =>
|
|
34
|
+
buildAuthnRequestRedirect(ctx, id, config),
|
|
35
|
+
"GET /callback": (ctx: MethodContext<SamlSpState>) =>
|
|
36
|
+
consumeAssertion(ctx, id, config),
|
|
37
|
+
"GET /metadata": (ctx: MethodContext<SamlSpState>) =>
|
|
38
|
+
buildSpMetadata(ctx, id, config),
|
|
39
|
+
// Front-channel Single Logout. `/sls` receives IdP LogoutRequest
|
|
40
|
+
// / LogoutResponse (both bindings; verified by XML-DSig, not a
|
|
41
|
+
// flow cookie — see sls.ts). `POST /logout` is the host-driven
|
|
42
|
+
// SP-initiated send (see slo-initiate.ts).
|
|
43
|
+
"GET /sls": (ctx: MethodContext<SamlSpState>) =>
|
|
44
|
+
consumeSls(ctx, id, config),
|
|
45
|
+
"POST /sls": (ctx: MethodContext<SamlSpState>) =>
|
|
46
|
+
consumeSls(ctx, id, config),
|
|
47
|
+
"POST /logout": (ctx: MethodContext<SamlSpState>) =>
|
|
48
|
+
initiateSpLogout(ctx, id, config),
|
|
49
|
+
},
|
|
50
|
+
// Anonymous, no flow cookie. `/metadata` always; the SLO routes
|
|
51
|
+
// (`/sls` receive + `/logout` send) only when an IdP SLO endpoint
|
|
52
|
+
// is configured — advertising/serving an SLO surface we cannot
|
|
53
|
+
// complete a round-trip on would break interop (same conservative
|
|
54
|
+
// gating as `unsolicitedCallback` ⇐ `idpInitiated`).
|
|
55
|
+
publicRoutes: [
|
|
56
|
+
"GET /metadata",
|
|
57
|
+
...(config.idp.sloUrl
|
|
58
|
+
? (["GET /sls", "POST /sls", "POST /logout"] as const)
|
|
59
|
+
: ([] as const)),
|
|
60
|
+
],
|
|
61
|
+
// IdP-initiated SSO: only when this instance is configured for it.
|
|
62
|
+
// Absent ⇒ unsolicited Responses stay invalid_request.
|
|
63
|
+
unsolicitedCallback: config.idpInitiated !== undefined,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
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(xml: string): Result<SamlIdpConfig> {
|
|
70
|
+
if (typeof xml !== "string" || xml.trim().length === 0) {
|
|
71
|
+
return err(authError.invalidRequest("metadata XML is empty"))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let doc: Document
|
|
75
|
+
try {
|
|
76
|
+
doc = new DOMParser().parseFromString(
|
|
77
|
+
xml,
|
|
78
|
+
"text/xml",
|
|
79
|
+
) as unknown as Document
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return err(
|
|
82
|
+
authError.invalidRequest(
|
|
83
|
+
`metadata XML is not well-formed: ${
|
|
84
|
+
e instanceof Error ? e.message : String(e)
|
|
85
|
+
}`,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (!doc?.documentElement) {
|
|
90
|
+
return err(authError.invalidRequest("metadata XML has no root element"))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entityDescriptor = els(doc, "EntityDescriptor")[0]
|
|
94
|
+
if (!entityDescriptor) {
|
|
95
|
+
return err(authError.invalidRequest("metadata has no <EntityDescriptor>"))
|
|
96
|
+
}
|
|
97
|
+
const entityId = entityDescriptor.getAttribute("entityID")
|
|
98
|
+
if (!entityId) {
|
|
99
|
+
return err(authError.invalidRequest("EntityDescriptor has no entityID"))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const idp = els(entityDescriptor, "IDPSSODescriptor")[0]
|
|
103
|
+
if (!idp) {
|
|
104
|
+
return err(
|
|
105
|
+
authError.invalidRequest(
|
|
106
|
+
"not an IdP metadata document (no <IDPSSODescriptor>) — this looks " +
|
|
107
|
+
"like SP metadata",
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ssoUrl = pickEndpoint(els(idp, "SingleSignOnService"))
|
|
113
|
+
if (!ssoUrl) {
|
|
114
|
+
return err(
|
|
115
|
+
authError.invalidRequest(
|
|
116
|
+
"IDPSSODescriptor has no usable <SingleSignOnService> Location",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
const sloUrl = pickEndpoint(els(idp, "SingleLogoutService"))
|
|
121
|
+
|
|
122
|
+
let nameIdFormat: SamlNameIdFormat | undefined
|
|
123
|
+
for (const f of els(idp, "NameIDFormat")) {
|
|
124
|
+
const mapped = NAME_ID_FORMAT_BY_URN[(f.textContent ?? "").trim()]
|
|
125
|
+
if (mapped) {
|
|
126
|
+
nameIdFormat = mapped
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Signing certs: a KeyDescriptor with use="signing", or with no `use`
|
|
132
|
+
// (per SAML metadata spec an absent `use` means the key is valid for
|
|
133
|
+
// both signing and encryption).
|
|
134
|
+
const signingCerts: { pem: string }[] = []
|
|
135
|
+
for (const kd of els(idp, "KeyDescriptor")) {
|
|
136
|
+
const use = kd.getAttribute("use")
|
|
137
|
+
if (use && use !== "signing") continue
|
|
138
|
+
for (const cert of els(kd, "X509Certificate")) {
|
|
139
|
+
const body = (cert.textContent ?? "").trim()
|
|
140
|
+
if (body.length > 0) signingCerts.push({ pem: toPem(body) })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (signingCerts.length === 0) {
|
|
144
|
+
return err(
|
|
145
|
+
authError.invalidRequest(
|
|
146
|
+
"IDPSSODescriptor has no signing <X509Certificate>",
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return ok({
|
|
152
|
+
entityId,
|
|
153
|
+
ssoUrl,
|
|
154
|
+
...(sloUrl !== undefined ? { sloUrl } : {}),
|
|
155
|
+
...(nameIdFormat !== undefined ? { nameIdFormat } : {}),
|
|
156
|
+
signingCerts,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
/**
|
|
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
|
+
|
|
128
|
+
export function buildSamlInstance(
|
|
129
|
+
config: SamlSpConfig,
|
|
130
|
+
binding: SamlBindingContext,
|
|
131
|
+
nowMs: number,
|
|
132
|
+
): SamlInstance {
|
|
133
|
+
const activeCerts = selectActiveCertPems(config.idp.signingCerts, nowMs)
|
|
134
|
+
if (activeCerts.length === 0) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"saml-sp: no IdP signing certificate is currently within its " +
|
|
137
|
+
"validity window — check SamlSpConfig.idp.signingCerts notBefore/notAfter.",
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const identifierFormat =
|
|
142
|
+
config.idp.nameIdFormat !== undefined
|
|
143
|
+
? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
|
|
144
|
+
: null
|
|
145
|
+
|
|
146
|
+
return new SAML({
|
|
147
|
+
// MandatorySamlOptions
|
|
148
|
+
idpCert: activeCerts,
|
|
149
|
+
issuer: binding.spEntityId,
|
|
150
|
+
callbackUrl: binding.acsUrl,
|
|
151
|
+
// IdP endpoints
|
|
152
|
+
entryPoint: config.idp.ssoUrl,
|
|
153
|
+
idpIssuer: config.idp.entityId,
|
|
154
|
+
audience: binding.spEntityId,
|
|
155
|
+
identifierFormat,
|
|
156
|
+
// Security posture. A signed *assertion* is mandatory (identity +
|
|
157
|
+
// conditions + audience all live in the signed bytes); requiring
|
|
158
|
+
// the outer Response to also be signed is stricter than the
|
|
159
|
+
// Okta/Entra default and would reject the majority of real IdPs,
|
|
160
|
+
// so it is not required here. SP-initiated requires an outstanding
|
|
161
|
+
// InResponseTo (`always`); IdP-initiated is unsolicited so it must
|
|
162
|
+
// be `ifPresent` — the caller layers explicit assertion-ID replay
|
|
163
|
+
// dedup on top for that mode.
|
|
164
|
+
validateInResponseTo: binding.logout
|
|
165
|
+
? ValidateInResponseTo.never
|
|
166
|
+
: binding.idpInitiated
|
|
167
|
+
? ValidateInResponseTo.ifPresent
|
|
168
|
+
: ValidateInResponseTo.always,
|
|
169
|
+
wantAssertionsSigned: config.requireSignedAssertion ?? true,
|
|
170
|
+
wantAuthnResponseSigned: config.requireSignedResponse ?? false,
|
|
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 }),
|
|
191
|
+
cacheProvider: methodScratchCacheProvider(
|
|
192
|
+
binding.scratch,
|
|
193
|
+
IN_RESPONSE_TO_TTL_MS,
|
|
194
|
+
),
|
|
195
|
+
// O3: per-connection SP signing key (opt-in). node-saml signs the
|
|
196
|
+
// HTTP-Redirect AuthnRequest / logout messages with this PEM keypair.
|
|
197
|
+
...(binding.signing
|
|
198
|
+
? {
|
|
199
|
+
privateKey: binding.signing.privateKeyPem,
|
|
200
|
+
publicCert: binding.signing.certPem,
|
|
201
|
+
signatureAlgorithm: "sha256" as const,
|
|
202
|
+
}
|
|
203
|
+
: {}),
|
|
204
|
+
// SP-emitted LogoutResponse / LogoutRequest destination (the IdP's
|
|
205
|
+
// SLO endpoint). node-saml's logout URL builder reads `logoutUrl`.
|
|
206
|
+
...(binding.logoutUrl ? { logoutUrl: binding.logoutUrl } : {}),
|
|
207
|
+
// Encrypted-assertion decryption (opt-in per connection).
|
|
208
|
+
...(binding.decryptionPvk ? { decryptionPvk: binding.decryptionPvk } : {}),
|
|
209
|
+
})
|
|
210
|
+
}
|