@_mustachio/openauth 0.10.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.
- 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 +45 -0
- package/dist/esm/adapters/memory/token-store.js +13 -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 +35 -28
- package/dist/esm/domain/authorize.js +7 -2
- package/dist/esm/domain/callback.js +124 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +46 -4
- package/dist/esm/domain/dpop.js +122 -0
- package/dist/esm/domain/id-token.js +94 -0
- package/dist/esm/domain/introspect.js +5 -1
- package/dist/esm/domain/jwt.js +30 -2
- package/dist/esm/domain/logout.js +75 -0
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +83 -1
- package/dist/esm/domain/par.js +51 -0
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/refresh.js +12 -1
- package/dist/esm/domain/register.js +74 -0
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token-exchange.js +1 -0
- package/dist/esm/domain/token.js +53 -12
- package/dist/esm/domain/userinfo.js +38 -3
- 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 +92 -21
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/end-session.js +64 -0
- package/dist/esm/http/handlers/metadata.js +7 -4
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/par.js +53 -0
- package/dist/esm/http/handlers/register.js +46 -0
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/handlers/token.js +36 -6
- package/dist/esm/http/handlers/userinfo.js +35 -29
- package/dist/esm/http/middleware/tenant.js +6 -6
- package/dist/esm/http/router.js +10 -0
- package/dist/esm/http/schemas/authorize.js +8 -2
- package/dist/esm/http/schemas/end-session.js +13 -0
- package/dist/esm/http/schemas/par.js +9 -0
- package/dist/esm/http/schemas/register.js +16 -0
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +40 -34
- 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 +208 -0
- package/dist/esm/methods/saml-sp/attributes.js +75 -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 +70 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +69 -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 +49 -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/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- 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 +6 -1
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/token-store.d.ts +1 -0
- package/dist/types/adapters/memory/token-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/client.d.ts +19 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/domain/authorize.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/client-credentials.d.ts +2 -0
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/discovery.d.ts +37 -0
- package/dist/types/domain/discovery.d.ts.map +1 -1
- package/dist/types/domain/dpop.d.ts +61 -0
- package/dist/types/domain/dpop.d.ts.map +1 -0
- package/dist/types/domain/id-token.d.ts +83 -0
- package/dist/types/domain/id-token.d.ts.map +1 -0
- package/dist/types/domain/introspect.d.ts +8 -0
- package/dist/types/domain/introspect.d.ts.map +1 -1
- package/dist/types/domain/jwt.d.ts +27 -1
- package/dist/types/domain/jwt.d.ts.map +1 -1
- package/dist/types/domain/logout.d.ts +69 -0
- package/dist/types/domain/logout.d.ts.map +1 -0
- 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/par.d.ts +62 -0
- package/dist/types/domain/par.d.ts.map +1 -0
- package/dist/types/domain/refresh.d.ts +9 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts +33 -0
- package/dist/types/domain/register.d.ts.map +1 -0
- package/dist/types/domain/token-exchange.d.ts +2 -0
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +41 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/domain/userinfo.d.ts +44 -5
- package/dist/types/domain/userinfo.d.ts.map +1 -1
- 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/authorize.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/end-session.d.ts +3 -0
- package/dist/types/http/handlers/end-session.d.ts.map +1 -0
- package/dist/types/http/handlers/metadata.d.ts +1 -1
- package/dist/types/http/handlers/metadata.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/par.d.ts +3 -0
- package/dist/types/http/handlers/par.d.ts.map +1 -0
- package/dist/types/http/handlers/register.d.ts +3 -0
- package/dist/types/http/handlers/register.d.ts.map +1 -0
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/http/schemas/authorize.d.ts +40 -0
- package/dist/types/http/schemas/authorize.d.ts.map +1 -1
- package/dist/types/http/schemas/end-session.d.ts +31 -0
- package/dist/types/http/schemas/end-session.d.ts.map +1 -0
- package/dist/types/http/schemas/par.d.ts +20 -0
- package/dist/types/http/schemas/par.d.ts.map +1 -0
- package/dist/types/http/schemas/register.d.ts +42 -0
- package/dist/types/http/schemas/register.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -3
- 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 +40 -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 +704 -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 +42 -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 +81 -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 +204 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +40 -0
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +50 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/ports/token-store.d.ts +12 -0
- package/dist/types/ports/token-store.d.ts.map +1 -1
- package/dist/types/types/authorization.d.ts +23 -0
- package/dist/types/types/authorization.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +9 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/flow.d.ts +10 -0
- package/dist/types/types/flow.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +162 -1
- 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/tenant.d.ts +37 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/dist/types/types/token.d.ts +128 -0
- package/dist/types/types/token.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 +83 -1
- package/src/adapters/memory/token-store.ts +16 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/client.ts +32 -0
- package/src/domain/authorize.ts +9 -0
- package/src/domain/callback.ts +220 -7
- package/src/domain/client-credentials.ts +2 -0
- package/src/domain/discovery.ts +80 -2
- package/src/domain/dpop.ts +293 -0
- package/src/domain/id-token.ts +213 -0
- package/src/domain/introspect.ts +10 -0
- package/src/domain/jwt.ts +80 -1
- package/src/domain/logout.ts +180 -0
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +195 -1
- package/src/domain/par.ts +141 -0
- package/src/domain/refresh.ts +41 -0
- package/src/domain/register.ts +168 -0
- package/src/domain/token-exchange.ts +11 -0
- package/src/domain/token.ts +134 -8
- package/src/domain/userinfo.ts +121 -6
- package/src/http/context.ts +10 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +146 -24
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/end-session.ts +106 -0
- package/src/http/handlers/metadata.ts +7 -2
- package/src/http/handlers/method-route.ts +75 -2
- package/src/http/handlers/par.ts +84 -0
- package/src/http/handlers/register.ts +70 -0
- package/src/http/handlers/token.ts +56 -0
- package/src/http/handlers/userinfo.ts +62 -38
- package/src/http/router.ts +19 -0
- package/src/http/schemas/authorize.ts +27 -0
- package/src/http/schemas/end-session.ts +23 -0
- package/src/http/schemas/par.ts +23 -0
- package/src/http/schemas/register.ts +29 -0
- package/src/index.ts +21 -1
- package/src/methods/saml-sp/acs.ts +399 -0
- package/src/methods/saml-sp/attributes.ts +137 -0
- package/src/methods/saml-sp/authnrequest.ts +128 -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 +118 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +204 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
- package/src/methods/saml-sp/saml-instance.ts +173 -0
- package/src/methods/saml-sp/slo-initiate.ts +177 -0
- package/src/methods/saml-sp/sls.ts +306 -0
- package/src/methods/saml-sp/types.ts +203 -0
- package/src/ports/CONSISTENCY.md +77 -21
- package/src/ports/audit-log.ts +42 -0
- package/src/ports/session-store.ts +61 -0
- package/src/ports/token-store.ts +13 -0
- package/src/types/authorization.ts +25 -0
- package/src/types/error.ts +23 -0
- package/src/types/flow.ts +10 -0
- package/src/types/idp.ts +170 -0
- package/src/types/method.ts +110 -0
- package/src/types/tenant.ts +37 -0
- package/src/types/token.ts +135 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assertion Consumer Service — the inbound, security-critical half.
|
|
3
|
+
*
|
|
4
|
+
* Mounted at the `"GET /callback"` route key (the framework's
|
|
5
|
+
* universal `/cb/<methodId>` callback, GET+POST). By the time this
|
|
6
|
+
* runs, `handleCallback` has already MAC-verified the state envelope
|
|
7
|
+
* (carried as `RelayState`) and consumed the flow; `ctx.flow` is the
|
|
8
|
+
* consumed record and `ctx.methodState` carries the SP entityID + ACS
|
|
9
|
+
* URL we committed to at AuthnRequest time.
|
|
10
|
+
*
|
|
11
|
+
* The cryptographic gauntlet (XML-DSig verification on signed
|
|
12
|
+
* references only, XSW/XXE resistance, issuer/audience,
|
|
13
|
+
* NotBefore/NotOnOrAfter, InResponseTo single-use) is delegated to
|
|
14
|
+
* `@node-saml/node-saml`'s `validatePostResponseAsync` — the
|
|
15
|
+
* CVE-2025-54369/54419-hardened path (SAML-AD1). We do not
|
|
16
|
+
* reimplement XML-DSig. Any verification failure surfaces as a thrown
|
|
17
|
+
* error which we map to a controlled `denied`; only infrastructure /
|
|
18
|
+
* misconfiguration faults become `error`.
|
|
19
|
+
*
|
|
20
|
+
* `SubjectConfirmationData/@Recipient` (gauntlet item 6) is **not**
|
|
21
|
+
* enforced by node-saml, so it is checked here explicitly against the
|
|
22
|
+
* signed assertion — see `checkRecipient`.
|
|
23
|
+
*
|
|
24
|
+
* Two modes, discriminated by `ctx.flow`:
|
|
25
|
+
*
|
|
26
|
+
* - **SP-initiated** (`ctx.flow` set): `handleCallback` MAC-verified
|
|
27
|
+
* the state envelope and consumed the flow; binding (SP entityID +
|
|
28
|
+
* ACS) comes from `ctx.methodState` committed at AuthnRequest time.
|
|
29
|
+
* `InResponseTo` is single-use (`always` + the scratch cache), so a
|
|
30
|
+
* replayed Response fails on its already-consumed request id.
|
|
31
|
+
* - **IdP-initiated** (`ctx.flow === null`): an unsolicited Response,
|
|
32
|
+
* no AuthnRequest, no state envelope, no flow. Allowed only when
|
|
33
|
+
* the instance configured `idpInitiated` (the framework gates this
|
|
34
|
+
* via `AuthMethod.unsolicitedCallback`). Binding is *derived* from
|
|
35
|
+
* `ctx.dispatch` (issuer/ACS — same derivation as AuthnRequest /
|
|
36
|
+
* metadata, so no drift). `InResponseTo` is `ifPresent` (none
|
|
37
|
+
* exists), so single-use no longer covers replay — we add explicit
|
|
38
|
+
* **assertion-ID dedup** via `methodScratch` (TTL = the assertion's
|
|
39
|
+
* `NotOnOrAfter` + skew). Success carries `unsolicitedBinding` from
|
|
40
|
+
* `config.idpInitiated` for the framework to mint the code.
|
|
41
|
+
*/
|
|
42
|
+
// CJS interop per the SAML house-style note — default-import then
|
|
43
|
+
// destructure rather than relying on the named-export heuristic.
|
|
44
|
+
import xmldom from "@xmldom/xmldom"
|
|
45
|
+
|
|
46
|
+
import { authError } from "../../types/error"
|
|
47
|
+
import type { MethodContext, MethodResult } from "../../types/method"
|
|
48
|
+
|
|
49
|
+
import { mapProfile, type VerifiedProfile } from "./attributes"
|
|
50
|
+
import { buildSamlInstance, deriveSpEntityId } from "./saml-instance"
|
|
51
|
+
import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
|
|
52
|
+
|
|
53
|
+
const { DOMParser } = xmldom
|
|
54
|
+
|
|
55
|
+
type NodeSamlProfile = {
|
|
56
|
+
nameID?: string
|
|
57
|
+
nameIDFormat?: string
|
|
58
|
+
sessionIndex?: string
|
|
59
|
+
attributes?: Record<string, unknown>
|
|
60
|
+
getSamlResponseXml?: () => string
|
|
61
|
+
/** The signed assertion XML — the bytes xml-crypto verified. */
|
|
62
|
+
getAssertionXml?: () => string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Gauntlet item 6 — `SubjectConfirmationData/@Recipient`.
|
|
67
|
+
*
|
|
68
|
+
* node-saml enforces Issuer, AudienceRestriction,
|
|
69
|
+
* Conditions/SubjectConfirmation timestamps and `InResponseTo`, but it
|
|
70
|
+
* does **not** validate `@Recipient`. The SAML 2.0 Web Browser SSO
|
|
71
|
+
* profile (§4.1.4.3) requires a bearer `SubjectConfirmationData` whose
|
|
72
|
+
* `Recipient` is the ACS the assertion was delivered to. We read it
|
|
73
|
+
* from the **signed** assertion (`getAssertionXml()` — the verified
|
|
74
|
+
* bytes, never the unsigned outer Response) and compare to the exact
|
|
75
|
+
* ACS URL committed at AuthnRequest time. A real xml-crypto-verified
|
|
76
|
+
* DOM parse, not an xml2js-shape walk (which the plan rejected as more
|
|
77
|
+
* fragile than the documented gap).
|
|
78
|
+
*
|
|
79
|
+
* Returns `null` when the check passes; otherwise a non-`success`
|
|
80
|
+
* `MethodResult` (`denied` for a binding failure, `error` for a
|
|
81
|
+
* library/infra fault — matching the rest of this handler's
|
|
82
|
+
* classification).
|
|
83
|
+
*/
|
|
84
|
+
function checkRecipient(
|
|
85
|
+
profile: NodeSamlProfile,
|
|
86
|
+
acsUrl: string,
|
|
87
|
+
): MethodResult<SamlSpProperties, SamlSpState> | null {
|
|
88
|
+
if (typeof profile.getAssertionXml !== "function") {
|
|
89
|
+
// node-saml v5.1 populates this on a successful verify. Its
|
|
90
|
+
// absence means the library contract changed under us — fail loud
|
|
91
|
+
// rather than skip a security check we now promise.
|
|
92
|
+
return {
|
|
93
|
+
kind: "error",
|
|
94
|
+
error: authError.internalError(
|
|
95
|
+
"saml-sp: node-saml profile exposes no getAssertionXml(); cannot " +
|
|
96
|
+
"perform the Recipient binding check. Refusing to authenticate.",
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let doc: Document
|
|
102
|
+
try {
|
|
103
|
+
doc = new DOMParser().parseFromString(
|
|
104
|
+
profile.getAssertionXml(),
|
|
105
|
+
"text/xml",
|
|
106
|
+
) as unknown as Document
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return {
|
|
109
|
+
kind: "error",
|
|
110
|
+
error: authError.internalError(
|
|
111
|
+
"saml-sp: failed to parse the verified assertion for the " +
|
|
112
|
+
"Recipient check",
|
|
113
|
+
e,
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Namespace-agnostic: match by local name regardless of prefix.
|
|
119
|
+
const nodes = doc.getElementsByTagNameNS(
|
|
120
|
+
"*",
|
|
121
|
+
"SubjectConfirmationData",
|
|
122
|
+
)
|
|
123
|
+
const recipients: string[] = []
|
|
124
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
125
|
+
const r = nodes[i]?.getAttribute("Recipient")
|
|
126
|
+
if (r) recipients.push(r)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (recipients.length === 0) {
|
|
130
|
+
return {
|
|
131
|
+
kind: "denied",
|
|
132
|
+
reason:
|
|
133
|
+
"assertion has no SubjectConfirmationData/@Recipient binding it " +
|
|
134
|
+
"to this ACS",
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!recipients.includes(acsUrl)) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "denied",
|
|
140
|
+
reason: "recipient mismatch: assertion not addressed to this ACS",
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* For IdP-initiated replay dedup: pull the assertion `@ID` and the
|
|
148
|
+
* tightest `NotOnOrAfter` from the **verified** assertion XML (the same
|
|
149
|
+
* bytes `checkRecipient` reads). Returns `null` if the assertion XML is
|
|
150
|
+
* unavailable (caller treats that as a fail-loud error — we will not
|
|
151
|
+
* skip replay protection we promised).
|
|
152
|
+
*/
|
|
153
|
+
function extractReplayInfo(
|
|
154
|
+
profile: NodeSamlProfile,
|
|
155
|
+
): { assertionId: string; notOnOrAfterMs: number | null } | null {
|
|
156
|
+
if (typeof profile.getAssertionXml !== "function") return null
|
|
157
|
+
let doc: Document
|
|
158
|
+
try {
|
|
159
|
+
doc = new DOMParser().parseFromString(
|
|
160
|
+
profile.getAssertionXml(),
|
|
161
|
+
"text/xml",
|
|
162
|
+
) as unknown as Document
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
const root = doc.documentElement
|
|
167
|
+
const assertionId = root?.getAttribute("ID") ?? ""
|
|
168
|
+
if (!assertionId) return null
|
|
169
|
+
let earliest: number | null = null
|
|
170
|
+
for (const tag of ["Conditions", "SubjectConfirmationData"]) {
|
|
171
|
+
const els = doc.getElementsByTagNameNS("*", tag)
|
|
172
|
+
for (let i = 0; i < els.length; i++) {
|
|
173
|
+
const v = els[i]?.getAttribute("NotOnOrAfter")
|
|
174
|
+
if (!v) continue
|
|
175
|
+
const ms = Date.parse(v)
|
|
176
|
+
if (!Number.isNaN(ms)) earliest = earliest === null ? ms : Math.min(earliest, ms)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { assertionId, notOnOrAfterMs: earliest }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function consumeAssertion(
|
|
183
|
+
ctx: MethodContext<SamlSpState>,
|
|
184
|
+
methodId: string,
|
|
185
|
+
config: SamlSpConfig,
|
|
186
|
+
): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
|
|
187
|
+
// Discriminator: a consumed flow ⇒ SP-initiated; no flow ⇒ an
|
|
188
|
+
// unsolicited IdP-initiated POST (the framework only routes one here
|
|
189
|
+
// when this instance opted in via `unsolicitedCallback`).
|
|
190
|
+
const idpInitiated = ctx.flow === null
|
|
191
|
+
|
|
192
|
+
let spEntityId: string
|
|
193
|
+
let acsUrl: string
|
|
194
|
+
if (idpInitiated) {
|
|
195
|
+
if (!config.idpInitiated) {
|
|
196
|
+
// Fail-closed: should be unreachable (the framework gates on
|
|
197
|
+
// `unsolicitedCallback`, set only when idpInitiated is config'd).
|
|
198
|
+
return {
|
|
199
|
+
kind: "error",
|
|
200
|
+
error: authError.internalError(
|
|
201
|
+
"saml-sp: unsolicited Response reached a method with no " +
|
|
202
|
+
"idpInitiated config",
|
|
203
|
+
),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!ctx.dispatch) {
|
|
207
|
+
return {
|
|
208
|
+
kind: "error",
|
|
209
|
+
error: authError.internalError(
|
|
210
|
+
"saml-sp: IdP-initiated ACS dispatched without issuer context",
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Same derivation as AuthnRequest / metadata — no drift.
|
|
215
|
+
spEntityId = deriveSpEntityId(
|
|
216
|
+
ctx.dispatch.issuerUrl,
|
|
217
|
+
ctx.tenant.id,
|
|
218
|
+
methodId,
|
|
219
|
+
)
|
|
220
|
+
acsUrl = ctx.dispatch.callbackUrl
|
|
221
|
+
} else {
|
|
222
|
+
const state = ctx.methodState
|
|
223
|
+
if (!state || !state.spEntityId || !state.acsUrl) {
|
|
224
|
+
return {
|
|
225
|
+
kind: "error",
|
|
226
|
+
error: authError.internalError(
|
|
227
|
+
"saml-sp: ACS reached without AuthnRequest method state " +
|
|
228
|
+
"(spEntityId / acsUrl). The flow did not originate from this method.",
|
|
229
|
+
),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
spEntityId = state.spEntityId
|
|
233
|
+
acsUrl = state.acsUrl
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let samlResponse: string | null
|
|
237
|
+
let relayState: string | null
|
|
238
|
+
try {
|
|
239
|
+
const form = new URLSearchParams(await ctx.request.text())
|
|
240
|
+
samlResponse = form.get("SAMLResponse")
|
|
241
|
+
relayState = form.get("RelayState")
|
|
242
|
+
} catch {
|
|
243
|
+
return {
|
|
244
|
+
kind: "error",
|
|
245
|
+
error: authError.internalError("saml-sp: ACS could not read POST body"),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!samlResponse) {
|
|
249
|
+
return { kind: "denied", reason: "missing SAMLResponse" }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build is a configuration concern (e.g. no signing cert within its
|
|
253
|
+
// validity window, or a rotation gap). A throw here is an operator
|
|
254
|
+
// fault, NOT a user auth failure — surfacing it as `denied` would
|
|
255
|
+
// hide a misconfiguration behind per-user "access denied" noise.
|
|
256
|
+
let saml: ReturnType<typeof buildSamlInstance>
|
|
257
|
+
try {
|
|
258
|
+
saml = buildSamlInstance(
|
|
259
|
+
config,
|
|
260
|
+
{
|
|
261
|
+
spEntityId,
|
|
262
|
+
acsUrl,
|
|
263
|
+
scratch: ctx.methodScratch,
|
|
264
|
+
...(idpInitiated ? { idpInitiated: true } : {}),
|
|
265
|
+
// Encrypted assertions are opt-in per connection. Absent ⇒ no
|
|
266
|
+
// decryptionPvk ⇒ node-saml rejects an EncryptedAssertion.
|
|
267
|
+
...(config.allowEncryptedAssertions && config.decryptionKey
|
|
268
|
+
? { decryptionPvk: config.decryptionKey.privateKeyPem }
|
|
269
|
+
: {}),
|
|
270
|
+
},
|
|
271
|
+
Date.now(),
|
|
272
|
+
)
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return {
|
|
275
|
+
kind: "error",
|
|
276
|
+
error: authError.internalError(
|
|
277
|
+
`saml-sp: cannot construct verifier: ${
|
|
278
|
+
e instanceof Error ? e.message : String(e)
|
|
279
|
+
}`,
|
|
280
|
+
e,
|
|
281
|
+
),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let profile: NodeSamlProfile | null
|
|
286
|
+
try {
|
|
287
|
+
const result = await saml.validatePostResponseAsync({
|
|
288
|
+
SAMLResponse: samlResponse,
|
|
289
|
+
...(relayState !== null ? { RelayState: relayState } : {}),
|
|
290
|
+
})
|
|
291
|
+
profile = result.profile as NodeSamlProfile | null
|
|
292
|
+
} catch (e) {
|
|
293
|
+
// Every node-saml verification failure (bad/absent signature,
|
|
294
|
+
// signature-wrapping, issuer/audience mismatch, expired
|
|
295
|
+
// conditions, unknown InResponseTo) throws here. These are
|
|
296
|
+
// controlled auth failures, not server faults.
|
|
297
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
298
|
+
// node-saml throws this exact message when an EncryptedAssertion
|
|
299
|
+
// arrives but no decryptionPvk is configured. Give the operator a
|
|
300
|
+
// signal that the *connection* is mis/under-configured rather than
|
|
301
|
+
// a generic "assertion rejected".
|
|
302
|
+
if (
|
|
303
|
+
!config.allowEncryptedAssertions &&
|
|
304
|
+
msg.includes("No decryption key")
|
|
305
|
+
) {
|
|
306
|
+
return {
|
|
307
|
+
kind: "denied",
|
|
308
|
+
reason:
|
|
309
|
+
"encrypted assertion received but encrypted assertions are not " +
|
|
310
|
+
"enabled for this SAML connection (set allowEncryptedAssertions " +
|
|
311
|
+
"+ decryptionKey)",
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { kind: "denied", reason: `assertion rejected: ${msg}` }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!profile || !profile.nameID) {
|
|
318
|
+
return { kind: "denied", reason: "assertion produced no usable subject" }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Gauntlet item 6 — node-saml does not enforce @Recipient; we do,
|
|
322
|
+
// against the exact ACS URL the IdP saw (committed for SP-init,
|
|
323
|
+
// derived for IdP-init — identical value either way).
|
|
324
|
+
const recipientFailure = checkRecipient(profile, acsUrl)
|
|
325
|
+
if (recipientFailure) return recipientFailure
|
|
326
|
+
|
|
327
|
+
// IdP-init replay: no InResponseTo single-use to lean on, so dedup
|
|
328
|
+
// the signed assertion's @ID. SP-init does not need this (the
|
|
329
|
+
// request id is already single-use).
|
|
330
|
+
if (idpInitiated) {
|
|
331
|
+
const replay = extractReplayInfo(profile)
|
|
332
|
+
if (!replay) {
|
|
333
|
+
return {
|
|
334
|
+
kind: "error",
|
|
335
|
+
error: authError.internalError(
|
|
336
|
+
"saml-sp: cannot read assertion @ID for IdP-initiated replay " +
|
|
337
|
+
"protection. Refusing to authenticate.",
|
|
338
|
+
),
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const key = `idp-replay:${replay.assertionId}`
|
|
342
|
+
const seen = await ctx.methodScratch.get(key)
|
|
343
|
+
if (seen.ok) {
|
|
344
|
+
return {
|
|
345
|
+
kind: "denied",
|
|
346
|
+
reason: "assertion replay detected (assertion ID already seen)",
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const skewMs = (config.clockSkewSeconds ?? 60) * 1000
|
|
350
|
+
const now = Date.now()
|
|
351
|
+
const horizon =
|
|
352
|
+
replay.notOnOrAfterMs !== null
|
|
353
|
+
? replay.notOnOrAfterMs - now + skewMs
|
|
354
|
+
: 10 * 60_000
|
|
355
|
+
// Clamp: never below the skew window, never an unbounded entry.
|
|
356
|
+
const ttlMs = Math.min(Math.max(horizon, skewMs, 60_000), 24 * 60 * 60_000)
|
|
357
|
+
const recorded = await ctx.methodScratch.put(key, "1", ttlMs)
|
|
358
|
+
if (!recorded.ok) {
|
|
359
|
+
// The dedup store is unavailable — failing open would allow
|
|
360
|
+
// replay. Fail closed.
|
|
361
|
+
return {
|
|
362
|
+
kind: "error",
|
|
363
|
+
error: authError.internalError(
|
|
364
|
+
"saml-sp: could not record assertion ID for replay protection",
|
|
365
|
+
),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const verified: VerifiedProfile = {
|
|
371
|
+
nameID: profile.nameID,
|
|
372
|
+
nameIDFormat: profile.nameIDFormat ?? "",
|
|
373
|
+
...(profile.sessionIndex !== undefined
|
|
374
|
+
? { sessionIndex: profile.sessionIndex }
|
|
375
|
+
: {}),
|
|
376
|
+
attributes: profile.attributes ?? {},
|
|
377
|
+
responseXml: profile.getSamlResponseXml?.() ?? "",
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const mapped = mapProfile(verified, config.attributeMapping)
|
|
381
|
+
if ("error" in mapped) {
|
|
382
|
+
return { kind: "denied", reason: mapped.error }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
kind: "success",
|
|
387
|
+
providerSubject: mapped.providerSubject,
|
|
388
|
+
properties: mapped.properties,
|
|
389
|
+
...(idpInitiated && config.idpInitiated
|
|
390
|
+
? {
|
|
391
|
+
unsolicitedBinding: {
|
|
392
|
+
clientId: config.idpInitiated.defaultClientId,
|
|
393
|
+
redirectUri: config.idpInitiated.defaultRedirectUri,
|
|
394
|
+
scopes: config.idpInitiated.defaultScopes ?? [],
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
: {}),
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
authnInstant?: string
|
|
31
|
+
responseXml: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NAME_ID_FORMAT_BY_URN: Record<string, SamlNameIdFormat> = {
|
|
35
|
+
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent": "persistent",
|
|
36
|
+
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient": "transient",
|
|
37
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress": "emailAddress",
|
|
38
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified": "unspecified",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeNameIdFormat(urn: string): SamlNameIdFormat {
|
|
42
|
+
return NAME_ID_FORMAT_BY_URN[urn] ?? "unspecified"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** First value if array, the value if scalar, else undefined. */
|
|
46
|
+
function scalar(v: unknown): string | undefined {
|
|
47
|
+
if (typeof v === "string") return v
|
|
48
|
+
if (Array.isArray(v) && v.length > 0 && typeof v[0] === "string") {
|
|
49
|
+
return v[0]
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Preserve array shape for multi-valued attributes (e.g. groups). */
|
|
55
|
+
function multi(v: unknown): string | string[] | undefined {
|
|
56
|
+
if (typeof v === "string") return v
|
|
57
|
+
if (Array.isArray(v)) {
|
|
58
|
+
const strs = v.filter((x): x is string => typeof x === "string")
|
|
59
|
+
return strs.length > 0 ? strs : undefined
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveRef(
|
|
65
|
+
ref: SamlAttributeRef,
|
|
66
|
+
profile: VerifiedProfile,
|
|
67
|
+
): string | string[] | undefined {
|
|
68
|
+
if (ref.source === "nameId") return profile.nameID
|
|
69
|
+
return multi(profile.attributes[ref.name])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type MappedSubject = {
|
|
73
|
+
providerSubject: string
|
|
74
|
+
properties: SamlSpProperties
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build `(providerSubject, properties)` from a verified profile.
|
|
79
|
+
* Returns a string describing the failure when the configured subject
|
|
80
|
+
* source resolves to nothing — the caller turns that into a `denied`
|
|
81
|
+
* result rather than minting a subject-less success.
|
|
82
|
+
*/
|
|
83
|
+
export function mapProfile(
|
|
84
|
+
profile: VerifiedProfile,
|
|
85
|
+
mapping: SamlAttributeMapping,
|
|
86
|
+
): MappedSubject | { error: string } {
|
|
87
|
+
const subjectRef: SamlAttributeRef = mapping.subject ?? { source: "nameId" }
|
|
88
|
+
const subjectVal = scalar(resolveRef(subjectRef, profile))
|
|
89
|
+
if (!subjectVal) {
|
|
90
|
+
return {
|
|
91
|
+
error:
|
|
92
|
+
subjectRef.source === "nameId"
|
|
93
|
+
? "assertion NameID is empty; cannot derive subject"
|
|
94
|
+
: `subject attribute "${subjectRef.name}" missing from assertion`,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const attributes: Record<string, string | string[]> = {}
|
|
99
|
+
|
|
100
|
+
const put = (key: string, ref: SamlAttributeRef | undefined): void => {
|
|
101
|
+
if (!ref) return
|
|
102
|
+
const v = resolveRef(ref, profile)
|
|
103
|
+
if (v !== undefined) attributes[key] = v
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
put("email", mapping.email)
|
|
107
|
+
put("name", mapping.name)
|
|
108
|
+
put("groups", mapping.groups)
|
|
109
|
+
if (mapping.emailVerified) {
|
|
110
|
+
attributes["emailVerified"] = mapping.emailVerified.value
|
|
111
|
+
? "true"
|
|
112
|
+
: "false"
|
|
113
|
+
}
|
|
114
|
+
for (const [key, ref] of Object.entries(mapping.custom ?? {})) {
|
|
115
|
+
put(key, ref)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const authnInstant = profile.authnInstant
|
|
119
|
+
? Date.parse(profile.authnInstant)
|
|
120
|
+
: Date.now()
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
providerSubject: subjectVal,
|
|
124
|
+
properties: {
|
|
125
|
+
nameId: {
|
|
126
|
+
value: profile.nameID,
|
|
127
|
+
format: normalizeNameIdFormat(profile.nameIDFormat),
|
|
128
|
+
},
|
|
129
|
+
attributes,
|
|
130
|
+
...(profile.sessionIndex !== undefined
|
|
131
|
+
? { sessionIndex: profile.sessionIndex }
|
|
132
|
+
: {}),
|
|
133
|
+
authnInstant: Number.isNaN(authnInstant) ? Date.now() : authnInstant,
|
|
134
|
+
raw: { responseXml: profile.responseXml },
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|