@_mustachio/openauth 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/adapters/d1/index.js +18 -9
- package/dist/esm/adapters/d1/migrations.js +7 -2
- package/dist/esm/adapters/d1/session-store.js +39 -0
- package/dist/esm/adapters/d1/session.js +2 -2
- package/dist/esm/adapters/durable-object/index.js +4 -0
- package/dist/esm/adapters/durable-object/session-store.js +42 -0
- package/dist/esm/adapters/dynamo/index.js +24 -7
- package/dist/esm/adapters/dynamo/session-store.js +49 -0
- package/dist/esm/adapters/kms/index.js +6 -2
- package/dist/esm/adapters/kv/index.js +6 -2
- package/dist/esm/adapters/memory/index.js +24 -7
- package/dist/esm/adapters/memory/session-store.js +23 -0
- package/dist/esm/adapters/postgres/executor.js +2 -2
- package/dist/esm/adapters/postgres/index.js +33 -10
- package/dist/esm/adapters/postgres/migrations.js +8 -2
- package/dist/esm/adapters/postgres/session-store.js +42 -0
- package/dist/esm/client.js +27 -27
- package/dist/esm/domain/authorize.js +2 -2
- package/dist/esm/domain/callback.js +121 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +2 -2
- package/dist/esm/domain/dpop.js +4 -4
- package/dist/esm/domain/id-token.js +5 -5
- package/dist/esm/domain/jwt.js +4 -4
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +80 -1
- package/dist/esm/domain/par.js +2 -2
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token.js +5 -5
- package/dist/esm/error.js +8 -8
- package/dist/esm/http/cookies.js +19 -2
- package/dist/esm/http/errors.js +4 -4
- package/dist/esm/http/handlers/authorize.js +3 -19
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/metadata.js +2 -2
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +31 -6
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/http/schemas/authorize.js +2 -2
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +34 -32
- package/dist/esm/methods/oauth2-factory.js +2 -2
- package/dist/esm/methods/password-hash.js +2 -2
- package/dist/esm/methods/providers/index.js +30 -14
- package/dist/esm/methods/saml-sp/acs.js +232 -0
- package/dist/esm/methods/saml-sp/attributes.js +77 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
- package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
- package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
- package/dist/esm/methods/saml-sp/factory.js +81 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +71 -0
- package/dist/esm/methods/saml-sp/method.js +29 -0
- package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
- package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
- package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
- package/dist/esm/methods/saml-sp/sls.js +154 -0
- package/dist/esm/methods/saml-sp/types.js +0 -0
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/types/scim.js +0 -0
- package/dist/esm/ui/forms.js +3 -3
- package/dist/esm/ui/index.js +8 -5
- package/dist/esm/ui/picker.js +2 -2
- package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
- package/dist/types/adapters/d1/session-store.d.ts +3 -0
- package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
- package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
- package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
- package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
- package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/session-store.d.ts +3 -0
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
- package/dist/types/adapters/postgres/session-store.d.ts +3 -0
- package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
- package/dist/types/domain/callback.d.ts +16 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +57 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +6 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/cookies.d.ts +9 -1
- package/dist/types/http/cookies.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts +4 -0
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/factory.d.ts +920 -0
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/index.d.ts +24 -0
- package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/method.d.ts +17 -0
- package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/sls.d.ts +4 -0
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/types.d.ts +299 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +18 -5
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts +23 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +83 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +109 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/adapters/d1/migrations.ts +5 -0
- package/src/adapters/d1/session-store.ts +68 -0
- package/src/adapters/durable-object/session-store.ts +61 -0
- package/src/adapters/dynamo/session-store.ts +63 -0
- package/src/adapters/memory/session-store.ts +39 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/domain/callback.ts +215 -7
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +190 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +9 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -22
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +72 -2
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +23 -0
- package/src/methods/saml-sp/acs.ts +449 -0
- package/src/methods/saml-sp/attributes.ts +146 -0
- package/src/methods/saml-sp/authnrequest.ts +129 -0
- package/src/methods/saml-sp/cache-provider.ts +76 -0
- package/src/methods/saml-sp/cert-rotation.ts +34 -0
- package/src/methods/saml-sp/factory.ts +139 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +215 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
- package/src/methods/saml-sp/saml-instance.ts +210 -0
- package/src/methods/saml-sp/slo-initiate.ts +180 -0
- package/src/methods/saml-sp/sls.ts +307 -0
- package/src/methods/saml-sp/types.ts +298 -0
- package/src/ports/CONSISTENCY.md +52 -24
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +24 -0
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +86 -0
- package/src/types/method.ts +110 -0
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +10 -0
|
@@ -0,0 +1,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"
|