@_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.
Files changed (208) 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/scim/discovery.js +206 -0
  32. package/dist/esm/domain/scim/filter.js +177 -0
  33. package/dist/esm/domain/scim/handle.js +338 -0
  34. package/dist/esm/domain/scim/patch.js +568 -0
  35. package/dist/esm/domain/scim/resource.js +256 -0
  36. package/dist/esm/domain/state-envelope.js +2 -2
  37. package/dist/esm/domain/token.js +5 -5
  38. package/dist/esm/error.js +8 -8
  39. package/dist/esm/http/cookies.js +19 -2
  40. package/dist/esm/http/errors.js +4 -4
  41. package/dist/esm/http/handlers/authorize.js +3 -19
  42. package/dist/esm/http/handlers/callback.js +7 -1
  43. package/dist/esm/http/handlers/metadata.js +2 -2
  44. package/dist/esm/http/handlers/method-route.js +47 -2
  45. package/dist/esm/http/handlers/revocation.js +2 -2
  46. package/dist/esm/http/handlers/scim.js +59 -0
  47. package/dist/esm/http/middleware/tenant.js +31 -6
  48. package/dist/esm/http/router.js +3 -0
  49. package/dist/esm/http/schemas/authorize.js +2 -2
  50. package/dist/esm/http/schemas/revocation.js +2 -2
  51. package/dist/esm/http/schemas/token.js +7 -7
  52. package/dist/esm/index.js +34 -32
  53. package/dist/esm/methods/oauth2-factory.js +2 -2
  54. package/dist/esm/methods/password-hash.js +2 -2
  55. package/dist/esm/methods/providers/index.js +30 -14
  56. package/dist/esm/methods/saml-sp/acs.js +232 -0
  57. package/dist/esm/methods/saml-sp/attributes.js +77 -0
  58. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  59. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  60. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  61. package/dist/esm/methods/saml-sp/factory.js +81 -0
  62. package/dist/esm/methods/saml-sp/index.js +7 -0
  63. package/dist/esm/methods/saml-sp/metadata.js +71 -0
  64. package/dist/esm/methods/saml-sp/method.js +29 -0
  65. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  66. package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
  67. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  68. package/dist/esm/methods/saml-sp/sls.js +154 -0
  69. package/dist/esm/methods/saml-sp/types.js +0 -0
  70. package/dist/esm/ports/scim-directory.js +0 -0
  71. package/dist/esm/types/error.js +5 -0
  72. package/dist/esm/types/result.js +3 -3
  73. package/dist/esm/types/scim.js +0 -0
  74. package/dist/esm/ui/forms.js +3 -3
  75. package/dist/esm/ui/index.js +8 -5
  76. package/dist/esm/ui/picker.js +2 -2
  77. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  79. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  80. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  81. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  82. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  83. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  84. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  85. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  86. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  87. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  88. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  89. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  90. package/dist/types/domain/callback.d.ts +16 -1
  91. package/dist/types/domain/callback.d.ts.map +1 -1
  92. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  93. package/dist/types/domain/method-route.d.ts +57 -1
  94. package/dist/types/domain/method-route.d.ts.map +1 -1
  95. package/dist/types/domain/scim/discovery.d.ts +11 -0
  96. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  97. package/dist/types/domain/scim/filter.d.ts +51 -0
  98. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  99. package/dist/types/domain/scim/handle.d.ts +35 -0
  100. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  101. package/dist/types/domain/scim/patch.d.ts +73 -0
  102. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  103. package/dist/types/domain/scim/resource.d.ts +56 -0
  104. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  105. package/dist/types/http/context.d.ts +6 -1
  106. package/dist/types/http/context.d.ts.map +1 -1
  107. package/dist/types/http/cookies.d.ts +9 -1
  108. package/dist/types/http/cookies.d.ts.map +1 -1
  109. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  110. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  111. package/dist/types/http/handlers/scim.d.ts +3 -0
  112. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  113. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  114. package/dist/types/http/router.d.ts.map +1 -1
  115. package/dist/types/index.d.ts +3 -1
  116. package/dist/types/index.d.ts.map +1 -1
  117. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
  120. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  121. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  122. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  123. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  124. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  125. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  126. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  127. package/dist/types/methods/saml-sp/factory.d.ts +920 -0
  128. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  129. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  130. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  131. package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
  132. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  133. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  134. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  135. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  136. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  137. package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
  138. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  139. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  140. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  141. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  142. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  143. package/dist/types/methods/saml-sp/types.d.ts +299 -0
  144. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  145. package/dist/types/ports/audit-log.d.ts +18 -5
  146. package/dist/types/ports/audit-log.d.ts.map +1 -1
  147. package/dist/types/ports/scim-directory.d.ts +104 -0
  148. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  149. package/dist/types/ports/session-store.d.ts +23 -0
  150. package/dist/types/ports/session-store.d.ts.map +1 -1
  151. package/dist/types/types/error.d.ts +5 -0
  152. package/dist/types/types/error.d.ts.map +1 -1
  153. package/dist/types/types/idp.d.ts +83 -0
  154. package/dist/types/types/idp.d.ts.map +1 -1
  155. package/dist/types/types/method.d.ts +109 -0
  156. package/dist/types/types/method.d.ts.map +1 -1
  157. package/dist/types/types/scim.d.ts +269 -0
  158. package/dist/types/types/scim.d.ts.map +1 -0
  159. package/dist/types/types/tenant.d.ts +10 -0
  160. package/dist/types/types/tenant.d.ts.map +1 -1
  161. package/package.json +9 -1
  162. package/src/adapters/d1/migrations.ts +5 -0
  163. package/src/adapters/d1/session-store.ts +68 -0
  164. package/src/adapters/durable-object/session-store.ts +61 -0
  165. package/src/adapters/dynamo/session-store.ts +63 -0
  166. package/src/adapters/memory/session-store.ts +39 -0
  167. package/src/adapters/postgres/migrations.ts +6 -0
  168. package/src/adapters/postgres/session-store.ts +69 -0
  169. package/src/domain/callback.ts +215 -7
  170. package/src/domain/method-dispatch.ts +53 -1
  171. package/src/domain/method-route.ts +190 -1
  172. package/src/domain/scim/discovery.ts +262 -0
  173. package/src/domain/scim/filter.ts +295 -0
  174. package/src/domain/scim/handle.ts +597 -0
  175. package/src/domain/scim/patch.ts +807 -0
  176. package/src/domain/scim/resource.ts +358 -0
  177. package/src/http/context.ts +9 -0
  178. package/src/http/cookies.ts +19 -1
  179. package/src/http/handlers/authorize.ts +2 -22
  180. package/src/http/handlers/callback.ts +9 -1
  181. package/src/http/handlers/method-route.ts +72 -2
  182. package/src/http/handlers/scim.ts +107 -0
  183. package/src/http/middleware/tenant.ts +47 -1
  184. package/src/http/router.ts +9 -0
  185. package/src/index.ts +23 -0
  186. package/src/methods/saml-sp/acs.ts +449 -0
  187. package/src/methods/saml-sp/attributes.ts +146 -0
  188. package/src/methods/saml-sp/authnrequest.ts +129 -0
  189. package/src/methods/saml-sp/cache-provider.ts +76 -0
  190. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  191. package/src/methods/saml-sp/factory.ts +139 -0
  192. package/src/methods/saml-sp/index.ts +34 -0
  193. package/src/methods/saml-sp/metadata.ts +215 -0
  194. package/src/methods/saml-sp/method.ts +65 -0
  195. package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
  196. package/src/methods/saml-sp/saml-instance.ts +210 -0
  197. package/src/methods/saml-sp/slo-initiate.ts +180 -0
  198. package/src/methods/saml-sp/sls.ts +307 -0
  199. package/src/methods/saml-sp/types.ts +298 -0
  200. package/src/ports/CONSISTENCY.md +52 -24
  201. package/src/ports/audit-log.ts +18 -5
  202. package/src/ports/scim-directory.ts +166 -0
  203. package/src/ports/session-store.ts +24 -0
  204. package/src/types/error.ts +11 -0
  205. package/src/types/idp.ts +86 -0
  206. package/src/types/method.ts +110 -0
  207. package/src/types/scim.ts +280 -0
  208. package/src/types/tenant.ts +10 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Attribute mapping — translate a node-saml `Profile` (already
3
+ * cryptographically verified by the time it reaches here) into our
4
+ * `SamlSpProperties`.
5
+ *
6
+ * node-saml flattens the assertion's `AttributeStatement` onto the
7
+ * `Profile` object as arbitrary top-level keys (single value → string,
8
+ * multi-valued → array), plus the structural `nameID` /
9
+ * `nameIDFormat` / `sessionIndex`. The host's `success` callback owns
10
+ * the final `SubjectClaim`; this layer just normalises the SAML side
11
+ * per the tenant's configured `SamlAttributeMapping`.
12
+ *
13
+ * Pure: no node-saml import, no I/O. `Profile` is modelled here as the
14
+ * minimal structural shape we read, so this file stays free of the
15
+ * third-party type (the public-API leak guard depends on that).
16
+ */
17
+ import type {
18
+ SamlAttributeMapping,
19
+ SamlAttributeRef,
20
+ SamlNameIdFormat,
21
+ SamlSpProperties,
22
+ } from "./types"
23
+
24
+ /** The slice of node-saml's `Profile` we actually consume. */
25
+ export type VerifiedProfile = {
26
+ nameID: string
27
+ nameIDFormat: string
28
+ sessionIndex?: string
29
+ attributes: Record<string, unknown>
30
+ /** `AuthnStatement/@AuthnInstant`, raw XSD dateTime. */
31
+ authnInstant?: string
32
+ /** `AuthnStatement/@SessionNotOnOrAfter`, already parsed to Unix ms. */
33
+ sessionNotOnOrAfter?: number
34
+ /** `AuthnContext/AuthnContextClassRef` — what the IdP actually asserted. */
35
+ authnContextClassRef?: string
36
+ responseXml: string
37
+ }
38
+
39
+ const NAME_ID_FORMAT_BY_URN: Record<string, SamlNameIdFormat> = {
40
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent": "persistent",
41
+ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient": "transient",
42
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress": "emailAddress",
43
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified": "unspecified",
44
+ }
45
+
46
+ function normalizeNameIdFormat(urn: string): SamlNameIdFormat {
47
+ return NAME_ID_FORMAT_BY_URN[urn] ?? "unspecified"
48
+ }
49
+
50
+ /** First value if array, the value if scalar, else undefined. */
51
+ function scalar(v: unknown): string | undefined {
52
+ if (typeof v === "string") return v
53
+ if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string") {
54
+ return v[0]
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ /** Preserve array shape for multi-valued attributes (e.g. groups). */
60
+ function multi(v: unknown): string | string[] | undefined {
61
+ if (typeof v === "string") return v
62
+ if (Array.isArray(v)) {
63
+ const strs = v.filter((x): x is string => typeof x === "string")
64
+ return strs.length > 0 ? strs : undefined
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ function resolveRef(
70
+ ref: SamlAttributeRef,
71
+ profile: VerifiedProfile,
72
+ ): string | string[] | undefined {
73
+ if (ref.source === "nameId") return profile.nameID
74
+ return multi(profile.attributes[ref.name])
75
+ }
76
+
77
+ export type MappedSubject = {
78
+ providerSubject: string
79
+ properties: SamlSpProperties
80
+ }
81
+
82
+ /**
83
+ * Build `(providerSubject, properties)` from a verified profile.
84
+ * Returns a string describing the failure when the configured subject
85
+ * source resolves to nothing — the caller turns that into a `denied`
86
+ * result rather than minting a subject-less success.
87
+ */
88
+ export function mapProfile(
89
+ profile: VerifiedProfile,
90
+ mapping: SamlAttributeMapping,
91
+ ): MappedSubject | { error: string } {
92
+ const subjectRef: SamlAttributeRef = mapping.subject ?? { source: "nameId" }
93
+ const subjectVal = scalar(resolveRef(subjectRef, profile))
94
+ if (!subjectVal) {
95
+ return {
96
+ error:
97
+ subjectRef.source === "nameId"
98
+ ? "assertion NameID is empty; cannot derive subject"
99
+ : `subject attribute "${subjectRef.name}" missing from assertion`,
100
+ }
101
+ }
102
+
103
+ const attributes: Record<string, string | string[]> = {}
104
+
105
+ const put = (key: string, ref: SamlAttributeRef | undefined): void => {
106
+ if (!ref) return
107
+ const v = resolveRef(ref, profile)
108
+ if (v !== undefined) attributes[key] = v
109
+ }
110
+
111
+ put("email", mapping.email)
112
+ put("name", mapping.name)
113
+ put("groups", mapping.groups)
114
+ if (mapping.emailVerified) {
115
+ attributes["emailVerified"] = mapping.emailVerified.value ? "true" : "false"
116
+ }
117
+ for (const [key, ref] of Object.entries(mapping.custom ?? {})) {
118
+ put(key, ref)
119
+ }
120
+
121
+ const authnInstant = profile.authnInstant
122
+ ? Date.parse(profile.authnInstant)
123
+ : Date.now()
124
+
125
+ return {
126
+ providerSubject: subjectVal,
127
+ properties: {
128
+ nameId: {
129
+ value: profile.nameID,
130
+ format: normalizeNameIdFormat(profile.nameIDFormat),
131
+ },
132
+ attributes,
133
+ ...(profile.sessionIndex !== undefined
134
+ ? { sessionIndex: profile.sessionIndex }
135
+ : {}),
136
+ authnInstant: Number.isNaN(authnInstant) ? Date.now() : authnInstant,
137
+ ...(profile.sessionNotOnOrAfter !== undefined
138
+ ? { sessionNotOnOrAfter: profile.sessionNotOnOrAfter }
139
+ : {}),
140
+ ...(profile.authnContextClassRef !== undefined
141
+ ? { authnContextClassRef: profile.authnContextClassRef }
142
+ : {}),
143
+ raw: { responseXml: profile.responseXml },
144
+ },
145
+ }
146
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `GET /authorize` — SP-initiated SSO. Builds a SAML `AuthnRequest`
3
+ * via node-saml and redirects the user agent to the IdP's SSO
4
+ * endpoint (HTTP-Redirect binding).
5
+ *
6
+ * The framework state envelope (`ctx.dispatch.state`) is carried as
7
+ * RelayState; the IdP echoes it back on the ACS POST so the standard
8
+ * callback machinery can recover the tenant + flow. node-saml records
9
+ * the generated request id in the `methodScratch`-backed cache for
10
+ * `InResponseTo` enforcement at the ACS.
11
+ */
12
+ import { authError } from "../../types/error"
13
+ import type { MethodContext, MethodResult } from "../../types/method"
14
+ import { isErr } from "../../types/result"
15
+
16
+ import { buildSamlInstance, resolveSpEntityId } from "./saml-instance"
17
+ import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
18
+
19
+ export async function buildAuthnRequestRedirect(
20
+ ctx: MethodContext<SamlSpState>,
21
+ methodId: string,
22
+ config: SamlSpConfig,
23
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
24
+ if (!ctx.dispatch) {
25
+ return {
26
+ kind: "error",
27
+ error: authError.internalError("saml-sp: dispatch missing on /authorize"),
28
+ }
29
+ }
30
+
31
+ // O3: signed AuthnRequest uses a per-connection SP keypair from
32
+ // config (decoupled from the OIDC KeyStore). The Zod schema already
33
+ // enforces `signAuthnRequest ⇒ signingKey`; guard defensively so a
34
+ // schema regression can never silently emit an unsigned request when
35
+ // the operator asked for a signed one.
36
+ if (config.signAuthnRequest && !config.signingKey) {
37
+ return {
38
+ kind: "error",
39
+ error: authError.internalError(
40
+ "saml-sp: signAuthnRequest is true but signingKey is missing " +
41
+ "(config schema should have rejected this).",
42
+ ),
43
+ }
44
+ }
45
+
46
+ const spEntityId = resolveSpEntityId(
47
+ config,
48
+ ctx.dispatch.issuerUrl,
49
+ ctx.tenant.id,
50
+ methodId,
51
+ )
52
+
53
+ const acsUrl = ctx.dispatch.callbackUrl
54
+
55
+ // Fail fast if the SessionStore adapter doesn't implement the
56
+ // scratch trio. node-saml ignores its CacheProvider.saveAsync
57
+ // return value, so without this probe we would issue an
58
+ // AuthnRequest whose request id is never cached — every assertion
59
+ // would then be rejected at the ACS with an opaque
60
+ // "InResponseTo not valid", and the operator would have no signal
61
+ // that the real problem is an unsupported adapter. Mirrors the
62
+ // explicit signAuthnRequest guard above.
63
+ const probe = await ctx.methodScratch.put(
64
+ "authnrequest-scratch-probe",
65
+ "1",
66
+ 1000,
67
+ )
68
+ if (isErr(probe)) {
69
+ return {
70
+ kind: "error",
71
+ error: authError.internalError(
72
+ "saml-sp: the configured SessionStore adapter does not support " +
73
+ "methodScratch (saveScratch/readScratch/deleteScratch), which " +
74
+ "SAML SP requires for InResponseTo replay protection. Deploy " +
75
+ "SAML against an adapter that implements the scratch trio.",
76
+ ),
77
+ }
78
+ }
79
+
80
+ let redirectUrl: string
81
+ try {
82
+ const saml = buildSamlInstance(
83
+ config,
84
+ {
85
+ spEntityId,
86
+ acsUrl,
87
+ scratch: ctx.methodScratch,
88
+ ...(config.signAuthnRequest && config.signingKey
89
+ ? {
90
+ signing: {
91
+ privateKeyPem: config.signingKey.privateKeyPem,
92
+ certPem: config.signingKey.certPem,
93
+ },
94
+ }
95
+ : {}),
96
+ },
97
+ Date.now(),
98
+ )
99
+ redirectUrl = await saml.getAuthorizeUrlAsync(
100
+ ctx.dispatch.state,
101
+ undefined,
102
+ {},
103
+ )
104
+ } catch (e) {
105
+ return {
106
+ kind: "error",
107
+ error: authError.internalError(
108
+ `saml-sp: failed to build AuthnRequest: ${
109
+ e instanceof Error ? e.message : String(e)
110
+ }`,
111
+ e,
112
+ ),
113
+ }
114
+ }
115
+
116
+ return {
117
+ kind: "challenge",
118
+ response: new Response(null, {
119
+ status: 302,
120
+ headers: { location: redirectUrl },
121
+ }),
122
+ saveMethodState: {
123
+ relayState: ctx.dispatch.state,
124
+ issuedAt: Date.now(),
125
+ spEntityId,
126
+ acsUrl,
127
+ },
128
+ }
129
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `@node-saml/node-saml` `CacheProvider` backed by `methodScratch`.
3
+ *
4
+ * node-saml tracks outstanding `AuthnRequest` IDs through a
5
+ * `CacheProvider`: `saveAsync(requestId, marker)` when the request is
6
+ * generated, then `getAsync` + `removeAsync` during response
7
+ * validation to enforce `InResponseTo` (defeats unsolicited /
8
+ * replayed Responses on the SP-initiated path).
9
+ *
10
+ * The default in-memory provider is single-process only. We back it
11
+ * with `MethodContext.methodScratch`, which is already scoped per
12
+ * `(tenantId, methodId)` and shared across instances via the
13
+ * `SessionStore` adapter — so an AuthnRequest minted on one node
14
+ * validates on another.
15
+ *
16
+ * `methodScratch.get` returning `unknown_state` (missing / expired)
17
+ * maps to `null`, which is exactly node-saml's "unknown request id"
18
+ * signal.
19
+ */
20
+ import type { MethodScratch } from "../../types/method"
21
+
22
+ import { isOk } from "../../types/result"
23
+
24
+ type CacheItem = { value: string; createdAt: number }
25
+ type NodeSamlCacheProvider = {
26
+ saveAsync(key: string, value: string): Promise<CacheItem | null>
27
+ getAsync(key: string): Promise<string | null>
28
+ removeAsync(key: string | null): Promise<string | null>
29
+ }
30
+
31
+ /** Key namespace inside the (already tenant/method-scoped) scratch. */
32
+ const KEY_PREFIX = "saml-inresponseto:"
33
+
34
+ /**
35
+ * @param scratch Per-request `MethodContext.methodScratch`.
36
+ * @param ttlMs How long an outstanding request id stays valid. Should
37
+ * comfortably exceed the slowest realistic IdP login
38
+ * (the flow record itself expires independently).
39
+ */
40
+ export function methodScratchCacheProvider(
41
+ scratch: MethodScratch,
42
+ ttlMs: number,
43
+ ): NodeSamlCacheProvider {
44
+ return {
45
+ async saveAsync(key, value) {
46
+ const createdAt = Date.now()
47
+ const item: CacheItem = { value, createdAt }
48
+ const r = await scratch.put(
49
+ `${KEY_PREFIX}${key}`,
50
+ JSON.stringify(item),
51
+ ttlMs,
52
+ )
53
+ // node-saml treats a null return as "could not cache" and fails
54
+ // the request generation loudly — which is what we want if the
55
+ // backing store is unavailable.
56
+ return isOk(r) ? item : null
57
+ },
58
+
59
+ async getAsync(key) {
60
+ const r = await scratch.get(`${KEY_PREFIX}${key}`)
61
+ if (!isOk(r)) return null
62
+ try {
63
+ const item = JSON.parse(r.value) as CacheItem
64
+ return item.value
65
+ } catch {
66
+ return null
67
+ }
68
+ },
69
+
70
+ async removeAsync(key) {
71
+ if (key === null) return null
72
+ const r = await scratch.delete(`${KEY_PREFIX}${key}`)
73
+ return isOk(r) ? key : null
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Cert-rotation shim.
3
+ *
4
+ * `@node-saml/node-saml`'s `idpCert` accepts a single PEM, an array of
5
+ * PEMs, or a callback. Our config models hot rotation as an array of
6
+ * `{ pem, notBefore?, notAfter? }`. This pure function filters that
7
+ * array to the certs whose validity window covers `now`, producing the
8
+ * PEM list node-saml verifies against.
9
+ *
10
+ * A cert with no `notBefore` has no lower bound; no `notAfter` has no
11
+ * upper bound. `notBefore` is inclusive, `notAfter` is exclusive — a
12
+ * cert is active while `notBefore <= now < notAfter`. Overlapping
13
+ * windows are the whole point: during rotation both the outgoing and
14
+ * incoming cert are active so in-flight assertions from either key
15
+ * still verify.
16
+ *
17
+ * Returns `[]` when nothing is in window — the caller surfaces a
18
+ * configuration error rather than handing node-saml an empty cert set
19
+ * (which it would treat as "accept nothing").
20
+ */
21
+ import type { SamlIdpSigningCert } from "./types"
22
+
23
+ export function selectActiveCertPems(
24
+ certs: ReadonlyArray<SamlIdpSigningCert>,
25
+ nowMs: number,
26
+ ): string[] {
27
+ return certs
28
+ .filter((c) => {
29
+ if (c.notBefore !== undefined && nowMs < c.notBefore) return false
30
+ if (c.notAfter !== undefined && nowMs >= c.notAfter) return false
31
+ return true
32
+ })
33
+ .map((c) => c.pem)
34
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * SAML SP factory — `kind: "saml-sp"`.
3
+ *
4
+ * Parallels `oauth2Factory` / `oidcFactory`: a Zod `configSchema`
5
+ * validates the tenant-supplied `SamlSpConfig` blob (Zod 3.24+ is
6
+ * Standard Schema v1 conformant, so it satisfies
7
+ * `AuthMethodFactory.configSchema` directly), and `build` delegates to
8
+ * `buildSamlSpMethod`.
9
+ *
10
+ * No `@node-saml/*` import appears here — node-saml is reached only at
11
+ * request time inside the route handlers, so importing the factory
12
+ * type surface stays cheap and the public-API leak guard stays green.
13
+ */
14
+ import { z } from "zod"
15
+
16
+ import type { AuthMethod, AuthMethodFactory } from "../../types/method"
17
+
18
+ import { buildSamlSpMethod } from "./method"
19
+ import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
20
+
21
+ const attributeRefSchema = z.union([
22
+ z.object({ source: z.literal("nameId") }),
23
+ z.object({
24
+ source: z.literal("attribute"),
25
+ name: z.string().min(1),
26
+ format: z.string().optional(),
27
+ }),
28
+ ])
29
+
30
+ const attributeMappingSchema = z.object({
31
+ subject: attributeRefSchema.optional(),
32
+ email: attributeRefSchema.optional(),
33
+ emailVerified: z
34
+ .object({ source: z.literal("literal"), value: z.boolean() })
35
+ .optional(),
36
+ name: attributeRefSchema.optional(),
37
+ groups: attributeRefSchema.optional(),
38
+ custom: z.record(attributeRefSchema).optional(),
39
+ })
40
+
41
+ const signingCertSchema = z.object({
42
+ pem: z.string().min(1),
43
+ notBefore: z.number().optional(),
44
+ notAfter: z.number().optional(),
45
+ })
46
+
47
+ const idpSchema = z.object({
48
+ entityId: z.string().min(1),
49
+ ssoUrl: z.string().url(),
50
+ sloUrl: z.string().url().optional(),
51
+ nameIdFormat: z
52
+ .enum(["persistent", "transient", "emailAddress", "unspecified"])
53
+ .optional(),
54
+ signingCerts: z.array(signingCertSchema).min(1),
55
+ })
56
+
57
+ const idpInitiatedSchema = z.object({
58
+ defaultClientId: z.string().min(1),
59
+ defaultRedirectUri: z.string().url(),
60
+ defaultScopes: z.array(z.string()).optional(),
61
+ })
62
+
63
+ const samlSpConfigSchema = z
64
+ .object({
65
+ idp: idpSchema,
66
+ attributeMapping: attributeMappingSchema,
67
+ signAuthnRequest: z.boolean().optional(),
68
+ signingKey: z
69
+ .object({
70
+ privateKeyPem: z.string().min(1),
71
+ certPem: z.string().min(1),
72
+ })
73
+ .optional(),
74
+ allowEncryptedAssertions: z.boolean().optional(),
75
+ decryptionKey: z
76
+ .object({
77
+ privateKeyPem: z.string().min(1),
78
+ certPem: z.string().min(1),
79
+ })
80
+ .optional(),
81
+ idpInitiated: idpInitiatedSchema.optional(),
82
+ clockSkewSeconds: z.number().int().nonnegative().optional(),
83
+ spEntityId: z.string().min(1).optional(),
84
+ forceAuthn: z.boolean().optional(),
85
+ requestedAuthnContext: z
86
+ .object({
87
+ classRefs: z.array(z.string().min(1)).min(1),
88
+ comparison: z
89
+ .enum(["exact", "minimum", "maximum", "better"])
90
+ .optional(),
91
+ })
92
+ .optional(),
93
+ requireSignedAssertion: z.boolean().optional(),
94
+ requireSignedResponse: z.boolean().optional(),
95
+ })
96
+ .refine((c) => !c.signAuthnRequest || c.signingKey !== undefined, {
97
+ message: "signingKey is required when signAuthnRequest is true",
98
+ path: ["signingKey"],
99
+ })
100
+ .refine((c) => !c.allowEncryptedAssertions || c.decryptionKey !== undefined, {
101
+ message: "decryptionKey is required when allowEncryptedAssertions is true",
102
+ path: ["decryptionKey"],
103
+ })
104
+ // An assertion nobody signed, inside a Response nobody signed, is
105
+ // unauthenticated XML. Refuse the combination outright rather than
106
+ // let a connection be configured into accepting anything.
107
+ .refine(
108
+ (c) =>
109
+ (c.requireSignedAssertion ?? true) || c.requireSignedResponse === true,
110
+ {
111
+ message:
112
+ "requireSignedAssertion may only be false when requireSignedResponse " +
113
+ "is true — at least one signature is mandatory",
114
+ path: ["requireSignedAssertion"],
115
+ },
116
+ )
117
+
118
+ export type SamlSpFactoryConfig = z.infer<typeof samlSpConfigSchema>
119
+
120
+ /**
121
+ * Generic SAML 2.0 Service Provider factory.
122
+ *
123
+ * `kind: "saml-sp"`. Use a distinct `MethodConfig.id` per upstream IdP
124
+ * to register multiple SAML connections against a single tenant.
125
+ */
126
+ export const samlSpFactory: AuthMethodFactory<
127
+ SamlSpProperties,
128
+ SamlSpState,
129
+ SamlSpConfig
130
+ > = {
131
+ kind: "saml-sp",
132
+ configSchema: samlSpConfigSchema,
133
+ build: async ({
134
+ id,
135
+ kind,
136
+ config,
137
+ }): Promise<AuthMethod<SamlSpProperties, SamlSpState>> =>
138
+ buildSamlSpMethod(id, kind, config),
139
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@_mustachio/openauth/methods/saml-sp` — SAML 2.0 Service Provider
3
+ * method family.
4
+ *
5
+ * **Node-only.** This entry depends transitively on
6
+ * `@node-saml/node-saml` and `xml-crypto`, which require `node:crypto`
7
+ * and the `@xmldom/xmldom` DOM. Cloudflare Workers, browsers, and
8
+ * other edge runtimes cannot load this module; use the root
9
+ * `@_mustachio/openauth` entry (OIDC / OAuth methods) on those
10
+ * platforms.
11
+ *
12
+ * The library's root entry is verified edge-clean by
13
+ * `test/types/public-api-no-thirdparty-leaks.test.ts` and the
14
+ * complementary `saml-sp-no-thirdparty-leaks.test.ts` — neither this
15
+ * subpath's third-party deps nor its public types are reachable from
16
+ * `@_mustachio/openauth`.
17
+ *
18
+ * See `docs/plans/claude/saml-sp-plan.md` for the architectural
19
+ * decisions backing this surface (SAML-AD1–AD7) and the phase plan.
20
+ */
21
+ export { samlSpFactory } from "./factory"
22
+ export { parseSamlIdpMetadata } from "./parse-idp-metadata"
23
+
24
+ export type {
25
+ SamlAttributeMapping,
26
+ SamlAttributeRef,
27
+ SamlIdpConfig,
28
+ SamlIdpInitiatedConfig,
29
+ SamlIdpSigningCert,
30
+ SamlNameIdFormat,
31
+ SamlSpConfig,
32
+ SamlSpProperties,
33
+ SamlSpState,
34
+ } from "./types"