@_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.
- 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/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/middleware/tenant.js +6 -6
- 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 +33 -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 +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/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 +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/http/context.d.ts +3 -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/index.d.ts +1 -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 +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 +18 -5
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- 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/idp.d.ts +71 -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/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/http/context.ts +6 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -21
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +75 -2
- package/src/index.ts +3 -0
- 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 +25 -0
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/session-store.ts +28 -0
- package/src/types/idp.ts +74 -0
- package/src/types/method.ts +110 -0
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* `domain/method-route.handleMethodRoute` orchestrator handles flow lookup
|
|
8
8
|
* + dispatch + result translation.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
handleMethodRoute,
|
|
12
|
+
handlePublicMethodRoute,
|
|
13
|
+
} from "../../domain/method-route"
|
|
14
|
+
import type { RouteKey } from "../../domain/method-dispatch"
|
|
11
15
|
import { authError } from "../../types/error"
|
|
12
16
|
import { isErr } from "../../types/result"
|
|
13
17
|
|
|
14
|
-
import { applyResponsePolicy } from "../cookies"
|
|
18
|
+
import { applyResponsePolicy, cacheControlHeader } from "../cookies"
|
|
15
19
|
import type { HttpContext, HttpDeps } from "../context"
|
|
16
20
|
import { authorizeDirectErrorResponse } from "../errors"
|
|
17
21
|
|
|
@@ -23,6 +27,75 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
|
|
|
23
27
|
authError.invalidRequest("tenant unresolved"),
|
|
24
28
|
)
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
// Public fast-path (additive — leaves the cookie-gated path below
|
|
32
|
+
// byte-identical for every non-public request). Only a clean hit —
|
|
33
|
+
// well-formed `/m/<id>/<sub>`, method resolves, and the resolved
|
|
34
|
+
// method explicitly lists this route key in `publicRoutes` — is
|
|
35
|
+
// diverted here, cookie-free. Anything else falls through to the
|
|
36
|
+
// original flow-cookie-gated logic with its original error
|
|
37
|
+
// ordering. `methodCache.resolve` is a cache lookup; the duplicate
|
|
38
|
+
// resolve on the gated path is a cache hit.
|
|
39
|
+
{
|
|
40
|
+
const u = new URL(c.req.url)
|
|
41
|
+
const segs = u.pathname.split("/").filter(Boolean)
|
|
42
|
+
if (segs.length >= 3 && segs[0] === "m") {
|
|
43
|
+
const methodId = segs[1]!
|
|
44
|
+
const subPath = "/" + segs.slice(2).join("/")
|
|
45
|
+
const httpMethod =
|
|
46
|
+
c.req.method.toUpperCase() === "POST" ? "POST" : "GET"
|
|
47
|
+
const routeKey = `${httpMethod} ${subPath}` as RouteKey
|
|
48
|
+
const resolved = await deps.methodCache.resolve(
|
|
49
|
+
tenant.config,
|
|
50
|
+
methodId,
|
|
51
|
+
)
|
|
52
|
+
if (
|
|
53
|
+
!isErr(resolved) &&
|
|
54
|
+
resolved.value.publicRoutes?.includes(routeKey)
|
|
55
|
+
) {
|
|
56
|
+
const issuerUrl = c.get("issuerUrl")
|
|
57
|
+
const callbackHost =
|
|
58
|
+
deps.callbackHostFor?.(tenant.id) ?? new URL(issuerUrl).host
|
|
59
|
+
// Mirrors the ACS URL derivation in domain/authorize.ts
|
|
60
|
+
// (callbackHost/path/url). Source of truth is there; the
|
|
61
|
+
// metadata.test.ts anti-drift test asserts the emitted
|
|
62
|
+
// entityID/ACS equal what buildAuthnRequestRedirect derives.
|
|
63
|
+
const callbackUrl = `${new URL(issuerUrl).protocol}//${callbackHost}/cb/${methodId}`
|
|
64
|
+
const pub = await handlePublicMethodRoute(
|
|
65
|
+
{
|
|
66
|
+
rawRequest: c.req.raw,
|
|
67
|
+
tenant,
|
|
68
|
+
method: resolved.value,
|
|
69
|
+
route: routeKey,
|
|
70
|
+
subPath,
|
|
71
|
+
dispatch: { state: "", callbackUrl, issuerUrl },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
sessionStore: deps.sessionStore,
|
|
75
|
+
tokenStore: deps.tokenStore,
|
|
76
|
+
clock: deps.clock,
|
|
77
|
+
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
78
|
+
...(deps.onLogout ? { onLogout: deps.onLogout } : {}),
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
if (isErr(pub)) return authorizeDirectErrorResponse(pub.error)
|
|
82
|
+
if (pub.value.kind === "denied") {
|
|
83
|
+
return applyResponsePolicy(
|
|
84
|
+
new Response(`access_denied: ${pub.value.reason}`, {
|
|
85
|
+
status: 403,
|
|
86
|
+
headers: { "content-type": "text/plain" },
|
|
87
|
+
}),
|
|
88
|
+
{ cookieDefaults: deps.cookieDefaults },
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
return applyResponsePolicy(pub.value.response, {
|
|
92
|
+
cacheControl: cacheControlHeader(pub.value.cache),
|
|
93
|
+
cookieDefaults: deps.cookieDefaults,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
26
99
|
const cookies = c.get("cookies")
|
|
27
100
|
const flowId = cookies.get("idp.flow")
|
|
28
101
|
if (!flowId) {
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,8 @@ export type {
|
|
|
76
76
|
FailureEvent,
|
|
77
77
|
IdP,
|
|
78
78
|
IdPOptions,
|
|
79
|
+
LogoutEventInput,
|
|
80
|
+
LogoutHookResult,
|
|
79
81
|
PersistUpstreamTokens,
|
|
80
82
|
RegisterClient,
|
|
81
83
|
RegisterClientRequest,
|
|
@@ -246,6 +248,7 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
246
248
|
...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
|
|
247
249
|
resolveTenant: opts.resolveTenant,
|
|
248
250
|
success: opts.success,
|
|
251
|
+
...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
|
|
249
252
|
...(opts.persistUpstreamTokens
|
|
250
253
|
? { persistUpstreamTokens: opts.persistUpstreamTokens }
|
|
251
254
|
: {}),
|
|
@@ -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
|
+
}
|