@_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.
Files changed (164) hide show
  1. package/dist/esm/adapters/d1/index.js +18 -9
  2. package/dist/esm/adapters/d1/migrations.js +7 -2
  3. package/dist/esm/adapters/d1/session-store.js +39 -0
  4. package/dist/esm/adapters/d1/session.js +2 -2
  5. package/dist/esm/adapters/durable-object/index.js +4 -0
  6. package/dist/esm/adapters/durable-object/session-store.js +42 -0
  7. package/dist/esm/adapters/dynamo/index.js +24 -7
  8. package/dist/esm/adapters/dynamo/session-store.js +49 -0
  9. package/dist/esm/adapters/kms/index.js +6 -2
  10. package/dist/esm/adapters/kv/index.js +6 -2
  11. package/dist/esm/adapters/memory/index.js +24 -7
  12. package/dist/esm/adapters/memory/session-store.js +23 -0
  13. package/dist/esm/adapters/postgres/executor.js +2 -2
  14. package/dist/esm/adapters/postgres/index.js +33 -10
  15. package/dist/esm/adapters/postgres/migrations.js +8 -2
  16. package/dist/esm/adapters/postgres/session-store.js +42 -0
  17. package/dist/esm/client.js +27 -27
  18. package/dist/esm/domain/authorize.js +2 -2
  19. package/dist/esm/domain/callback.js +121 -7
  20. package/dist/esm/domain/client-auth.js +2 -2
  21. package/dist/esm/domain/crypto.js +14 -14
  22. package/dist/esm/domain/discovery.js +2 -2
  23. package/dist/esm/domain/dpop.js +4 -4
  24. package/dist/esm/domain/id-token.js +5 -5
  25. package/dist/esm/domain/jwt.js +4 -4
  26. package/dist/esm/domain/method-dispatch.js +27 -1
  27. package/dist/esm/domain/method-route.js +80 -1
  28. package/dist/esm/domain/par.js +2 -2
  29. package/dist/esm/domain/pkce.js +2 -2
  30. package/dist/esm/domain/revoke.js +2 -2
  31. package/dist/esm/domain/state-envelope.js +2 -2
  32. package/dist/esm/domain/token.js +5 -5
  33. package/dist/esm/error.js +8 -8
  34. package/dist/esm/http/cookies.js +19 -2
  35. package/dist/esm/http/errors.js +4 -4
  36. package/dist/esm/http/handlers/authorize.js +3 -19
  37. package/dist/esm/http/handlers/callback.js +7 -1
  38. package/dist/esm/http/handlers/metadata.js +2 -2
  39. package/dist/esm/http/handlers/method-route.js +47 -2
  40. package/dist/esm/http/handlers/revocation.js +2 -2
  41. package/dist/esm/http/middleware/tenant.js +6 -6
  42. package/dist/esm/http/schemas/authorize.js +2 -2
  43. package/dist/esm/http/schemas/revocation.js +2 -2
  44. package/dist/esm/http/schemas/token.js +7 -7
  45. package/dist/esm/index.js +33 -32
  46. package/dist/esm/methods/oauth2-factory.js +2 -2
  47. package/dist/esm/methods/password-hash.js +2 -2
  48. package/dist/esm/methods/providers/index.js +30 -14
  49. package/dist/esm/methods/saml-sp/acs.js +208 -0
  50. package/dist/esm/methods/saml-sp/attributes.js +75 -0
  51. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  52. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  53. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  54. package/dist/esm/methods/saml-sp/factory.js +70 -0
  55. package/dist/esm/methods/saml-sp/index.js +7 -0
  56. package/dist/esm/methods/saml-sp/metadata.js +69 -0
  57. package/dist/esm/methods/saml-sp/method.js +29 -0
  58. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  59. package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
  60. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  61. package/dist/esm/methods/saml-sp/sls.js +154 -0
  62. package/dist/esm/methods/saml-sp/types.js +0 -0
  63. package/dist/esm/types/result.js +3 -3
  64. package/dist/esm/ui/forms.js +3 -3
  65. package/dist/esm/ui/index.js +8 -5
  66. package/dist/esm/ui/picker.js +2 -2
  67. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  68. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  69. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  70. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  71. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  72. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  73. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  74. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  75. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  76. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  77. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  79. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  80. package/dist/types/domain/callback.d.ts +16 -1
  81. package/dist/types/domain/callback.d.ts.map +1 -1
  82. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  83. package/dist/types/domain/method-route.d.ts +57 -1
  84. package/dist/types/domain/method-route.d.ts.map +1 -1
  85. package/dist/types/http/context.d.ts +3 -1
  86. package/dist/types/http/context.d.ts.map +1 -1
  87. package/dist/types/http/cookies.d.ts +9 -1
  88. package/dist/types/http/cookies.d.ts.map +1 -1
  89. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  90. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  91. package/dist/types/index.d.ts +1 -1
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  94. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  95. package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
  96. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  97. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  98. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  99. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  100. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  101. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  102. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  103. package/dist/types/methods/saml-sp/factory.d.ts +704 -0
  104. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  105. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  106. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  107. package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
  108. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  109. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  110. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  111. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  112. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  113. package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
  114. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  115. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  116. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  117. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/types.d.ts +204 -0
  120. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  121. package/dist/types/ports/audit-log.d.ts +18 -5
  122. package/dist/types/ports/audit-log.d.ts.map +1 -1
  123. package/dist/types/ports/session-store.d.ts +23 -0
  124. package/dist/types/ports/session-store.d.ts.map +1 -1
  125. package/dist/types/types/idp.d.ts +71 -0
  126. package/dist/types/types/idp.d.ts.map +1 -1
  127. package/dist/types/types/method.d.ts +109 -0
  128. package/dist/types/types/method.d.ts.map +1 -1
  129. package/package.json +9 -1
  130. package/src/adapters/d1/migrations.ts +5 -0
  131. package/src/adapters/d1/session-store.ts +68 -0
  132. package/src/adapters/durable-object/session-store.ts +61 -0
  133. package/src/adapters/dynamo/session-store.ts +63 -0
  134. package/src/adapters/memory/session-store.ts +39 -0
  135. package/src/adapters/postgres/migrations.ts +6 -0
  136. package/src/adapters/postgres/session-store.ts +69 -0
  137. package/src/domain/callback.ts +215 -7
  138. package/src/domain/method-dispatch.ts +53 -1
  139. package/src/domain/method-route.ts +190 -1
  140. package/src/http/context.ts +6 -0
  141. package/src/http/cookies.ts +19 -1
  142. package/src/http/handlers/authorize.ts +2 -21
  143. package/src/http/handlers/callback.ts +9 -1
  144. package/src/http/handlers/method-route.ts +75 -2
  145. package/src/index.ts +3 -0
  146. package/src/methods/saml-sp/acs.ts +399 -0
  147. package/src/methods/saml-sp/attributes.ts +137 -0
  148. package/src/methods/saml-sp/authnrequest.ts +128 -0
  149. package/src/methods/saml-sp/cache-provider.ts +76 -0
  150. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  151. package/src/methods/saml-sp/factory.ts +118 -0
  152. package/src/methods/saml-sp/index.ts +34 -0
  153. package/src/methods/saml-sp/metadata.ts +204 -0
  154. package/src/methods/saml-sp/method.ts +65 -0
  155. package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
  156. package/src/methods/saml-sp/saml-instance.ts +173 -0
  157. package/src/methods/saml-sp/slo-initiate.ts +177 -0
  158. package/src/methods/saml-sp/sls.ts +306 -0
  159. package/src/methods/saml-sp/types.ts +203 -0
  160. package/src/ports/CONSISTENCY.md +25 -0
  161. package/src/ports/audit-log.ts +18 -5
  162. package/src/ports/session-store.ts +28 -0
  163. package/src/types/idp.ts +74 -0
  164. 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
+ }