@_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,128 @@
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, deriveSpEntityId } 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 = deriveSpEntityId(
47
+ ctx.dispatch.issuerUrl,
48
+ ctx.tenant.id,
49
+ methodId,
50
+ )
51
+
52
+ const acsUrl = ctx.dispatch.callbackUrl
53
+
54
+ // Fail fast if the SessionStore adapter doesn't implement the
55
+ // scratch trio. node-saml ignores its CacheProvider.saveAsync
56
+ // return value, so without this probe we would issue an
57
+ // AuthnRequest whose request id is never cached — every assertion
58
+ // would then be rejected at the ACS with an opaque
59
+ // "InResponseTo not valid", and the operator would have no signal
60
+ // that the real problem is an unsupported adapter. Mirrors the
61
+ // explicit signAuthnRequest guard above.
62
+ const probe = await ctx.methodScratch.put(
63
+ "authnrequest-scratch-probe",
64
+ "1",
65
+ 1000,
66
+ )
67
+ if (isErr(probe)) {
68
+ return {
69
+ kind: "error",
70
+ error: authError.internalError(
71
+ "saml-sp: the configured SessionStore adapter does not support " +
72
+ "methodScratch (saveScratch/readScratch/deleteScratch), which " +
73
+ "SAML SP requires for InResponseTo replay protection. Deploy " +
74
+ "SAML against an adapter that implements the scratch trio.",
75
+ ),
76
+ }
77
+ }
78
+
79
+ let redirectUrl: string
80
+ try {
81
+ const saml = buildSamlInstance(
82
+ config,
83
+ {
84
+ spEntityId,
85
+ acsUrl,
86
+ scratch: ctx.methodScratch,
87
+ ...(config.signAuthnRequest && config.signingKey
88
+ ? {
89
+ signing: {
90
+ privateKeyPem: config.signingKey.privateKeyPem,
91
+ certPem: config.signingKey.certPem,
92
+ },
93
+ }
94
+ : {}),
95
+ },
96
+ Date.now(),
97
+ )
98
+ redirectUrl = await saml.getAuthorizeUrlAsync(
99
+ ctx.dispatch.state,
100
+ undefined,
101
+ {},
102
+ )
103
+ } catch (e) {
104
+ return {
105
+ kind: "error",
106
+ error: authError.internalError(
107
+ `saml-sp: failed to build AuthnRequest: ${
108
+ e instanceof Error ? e.message : String(e)
109
+ }`,
110
+ e,
111
+ ),
112
+ }
113
+ }
114
+
115
+ return {
116
+ kind: "challenge",
117
+ response: new Response(null, {
118
+ status: 302,
119
+ headers: { location: redirectUrl },
120
+ }),
121
+ saveMethodState: {
122
+ relayState: ctx.dispatch.state,
123
+ issuedAt: Date.now(),
124
+ spEntityId,
125
+ acsUrl,
126
+ },
127
+ }
128
+ }
@@ -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,118 @@
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
+ })
84
+ .refine((c) => !c.signAuthnRequest || c.signingKey !== undefined, {
85
+ message: "signingKey is required when signAuthnRequest is true",
86
+ path: ["signingKey"],
87
+ })
88
+ .refine(
89
+ (c) => !c.allowEncryptedAssertions || c.decryptionKey !== undefined,
90
+ {
91
+ message:
92
+ "decryptionKey is required when allowEncryptedAssertions is true",
93
+ path: ["decryptionKey"],
94
+ },
95
+ )
96
+
97
+ export type SamlSpFactoryConfig = z.infer<typeof samlSpConfigSchema>
98
+
99
+ /**
100
+ * Generic SAML 2.0 Service Provider factory.
101
+ *
102
+ * `kind: "saml-sp"`. Use a distinct `MethodConfig.id` per upstream IdP
103
+ * to register multiple SAML connections against a single tenant.
104
+ */
105
+ export const samlSpFactory: AuthMethodFactory<
106
+ SamlSpProperties,
107
+ SamlSpState,
108
+ SamlSpConfig
109
+ > = {
110
+ kind: "saml-sp",
111
+ configSchema: samlSpConfigSchema,
112
+ build: async ({
113
+ id,
114
+ kind,
115
+ config,
116
+ }): Promise<AuthMethod<SamlSpProperties, SamlSpState>> =>
117
+ buildSamlSpMethod(id, kind, config),
118
+ }
@@ -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"
@@ -0,0 +1,204 @@
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
+ * (`deriveSpEntityId(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` /
22
+ * `WantAssertionsSigned="true"` truthfully state actual behaviour.
23
+ */
24
+ import { authError } from "../../types/error"
25
+ import type { MethodContext, MethodResult } from "../../types/method"
26
+
27
+ import { deriveSpEntityId } from "./saml-instance"
28
+ import type {
29
+ SamlNameIdFormat,
30
+ SamlSpConfig,
31
+ SamlSpProperties,
32
+ SamlSpState,
33
+ } from "./types"
34
+
35
+ const NAME_ID_FORMAT_URN: Record<SamlNameIdFormat, string> = {
36
+ persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
37
+ transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
38
+ emailAddress: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
39
+ unspecified: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
40
+ }
41
+
42
+ const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
43
+ const HTTP_REDIRECT =
44
+ "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
45
+
46
+ /** Minimal XML attribute/text escaping for URL-shaped values. */
47
+ function xmlEscape(s: string): string {
48
+ return s
49
+ .replace(/&/g, "&amp;")
50
+ .replace(/</g, "&lt;")
51
+ .replace(/>/g, "&gt;")
52
+ .replace(/"/g, "&quot;")
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
+
84
+ /** PEM cert body → bare base64 (SAML metadata X509Certificate form). */
85
+ function certBody(pem: string): string {
86
+ return pem
87
+ .replace(/-----BEGIN CERTIFICATE-----/g, "")
88
+ .replace(/-----END CERTIFICATE-----/g, "")
89
+ .replace(/\s+/g, "")
90
+ }
91
+
92
+ /**
93
+ * Pure SP metadata builder. Mirrors `parseSamlIdpMetadata`'s purity so
94
+ * it is trivially unit-testable and reusable.
95
+ */
96
+ export function buildSpMetadataXml(input: SpMetadataInput): string {
97
+ const entityId = xmlEscape(input.spEntityId)
98
+ const acs = xmlEscape(input.acsUrl)
99
+ const signed = input.signingCertPem !== undefined
100
+ const nameIdLine =
101
+ input.nameIdFormat !== undefined
102
+ ? `\n <md:NameIDFormat>${NAME_ID_FORMAT_URN[input.nameIdFormat]}</md:NameIDFormat>`
103
+ : ""
104
+ const x509 = (cert: string) =>
105
+ `<ds:KeyInfo><ds:X509Data><ds:X509Certificate>` +
106
+ certBody(cert) +
107
+ `</ds:X509Certificate></ds:X509Data></ds:KeyInfo>`
108
+ const keyDescriptor =
109
+ (signed
110
+ ? `\n <md:KeyDescriptor use="signing">` +
111
+ x509(input.signingCertPem as string) +
112
+ `</md:KeyDescriptor>`
113
+ : "") +
114
+ (input.encryptionCertPem !== undefined
115
+ ? `\n <md:KeyDescriptor use="encryption">` +
116
+ x509(input.encryptionCertPem) +
117
+ `</md:KeyDescriptor>`
118
+ : "")
119
+ // Schema order: KeyDescriptor → SingleLogoutService → NameIDFormat →
120
+ // AssertionConsumerService. Advertise both bindings we accept at /sls.
121
+ const sls =
122
+ input.slsUrl !== undefined
123
+ ? `\n <md:SingleLogoutService ` +
124
+ `Binding="${HTTP_REDIRECT}" Location="${xmlEscape(input.slsUrl)}"/>` +
125
+ `\n <md:SingleLogoutService ` +
126
+ `Binding="${HTTP_POST}" Location="${xmlEscape(input.slsUrl)}"/>`
127
+ : ""
128
+
129
+ return (
130
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
131
+ `<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ` +
132
+ `xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ` +
133
+ `entityID="${entityId}">\n` +
134
+ ` <md:SPSSODescriptor AuthnRequestsSigned="${signed}" ` +
135
+ `WantAssertionsSigned="true" ` +
136
+ `protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">` +
137
+ keyDescriptor +
138
+ sls +
139
+ nameIdLine +
140
+ `\n <md:AssertionConsumerService ` +
141
+ `Binding="${HTTP_POST}" Location="${acs}" index="0" isDefault="true"/>` +
142
+ `\n </md:SPSSODescriptor>\n` +
143
+ `</md:EntityDescriptor>\n`
144
+ )
145
+ }
146
+
147
+ /**
148
+ * `GET /metadata` route handler. Anonymous (declared in the method's
149
+ * `publicRoutes`): `ctx.flow` / `ctx.methodState` are null; everything
150
+ * comes from `ctx.tenant` + `ctx.dispatch` + captured config.
151
+ */
152
+ export async function buildSpMetadata(
153
+ ctx: MethodContext<SamlSpState>,
154
+ methodId: string,
155
+ config: SamlSpConfig,
156
+ ): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
157
+ if (!ctx.dispatch) {
158
+ return {
159
+ kind: "error",
160
+ error: authError.internalError(
161
+ "saml-sp: metadata route dispatched without issuer context " +
162
+ "(ctx.dispatch is null)",
163
+ ),
164
+ }
165
+ }
166
+
167
+ const spEntityId = deriveSpEntityId(
168
+ ctx.dispatch.issuerUrl,
169
+ ctx.tenant.id,
170
+ methodId,
171
+ )
172
+ // SP SLS URL — same host as the ACS callback, at the public method
173
+ // mount `/m/<methodId>/sls`. Only advertised when SLO is actually
174
+ // served (idp.sloUrl set ⇒ /sls is in publicRoutes). Derived from the
175
+ // same dispatch input as the ACS so it cannot drift.
176
+ const cb = new URL(ctx.dispatch.callbackUrl)
177
+ const slsUrl = `${cb.protocol}//${cb.host}/m/${methodId}/sls`
178
+ const xml = buildSpMetadataXml({
179
+ spEntityId,
180
+ acsUrl: ctx.dispatch.callbackUrl,
181
+ ...(config.idp.sloUrl ? { slsUrl } : {}),
182
+ ...(config.idp.nameIdFormat !== undefined
183
+ ? { nameIdFormat: config.idp.nameIdFormat }
184
+ : {}),
185
+ // Truthful: advertise signing iff we actually sign AuthnRequests.
186
+ ...(config.signAuthnRequest && config.signingKey
187
+ ? { signingCertPem: config.signingKey.certPem }
188
+ : {}),
189
+ // Truthful: advertise an encryption cert iff we accept (and can
190
+ // decrypt) encrypted assertions.
191
+ ...(config.allowEncryptedAssertions && config.decryptionKey
192
+ ? { encryptionCertPem: config.decryptionKey.certPem }
193
+ : {}),
194
+ })
195
+
196
+ return {
197
+ kind: "challenge",
198
+ response: new Response(xml, {
199
+ status: 200,
200
+ headers: { "content-type": "application/samlmetadata+xml" },
201
+ }),
202
+ cache: { sMaxAge: 300 },
203
+ }
204
+ }
@@ -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
+ }