@_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
|
@@ -32,7 +32,47 @@ import {
|
|
|
32
32
|
authorizeDirectErrorResponse,
|
|
33
33
|
tokenEndpointErrorResponse,
|
|
34
34
|
} from "../errors"
|
|
35
|
-
import { authError } from "../../types/error"
|
|
35
|
+
import { authError, type AuthError } from "../../types/error"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* SCIM has its own media type and error envelope, and its own
|
|
39
|
+
* disclosure rule: an unauthenticated caller must not be able to tell an
|
|
40
|
+
* unknown tenant from a known one. The shared OAuth error response
|
|
41
|
+
* defeats both — it answers `400 application/json` with the
|
|
42
|
+
* tenant-not-found text, which is visibly different from the `403` a
|
|
43
|
+
* configured-but-SCIM-disabled tenant gets.
|
|
44
|
+
*
|
|
45
|
+
* So SCIM paths collapse every tenant-resolution failure onto the same
|
|
46
|
+
* `403` the domain layer emits for "SCIM is not enabled here". A store
|
|
47
|
+
* failure is still distinguishable as a 500, because that is an
|
|
48
|
+
* operational fault rather than a fact about which tenants exist.
|
|
49
|
+
*/
|
|
50
|
+
function isScimPath(pathname: string): boolean {
|
|
51
|
+
return pathname === "/scim/v2" || pathname.startsWith("/scim/v2/")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scimTenantErrorResponse(error: AuthError): Response {
|
|
55
|
+
const operational =
|
|
56
|
+
error.code === "server_error" || error.code === "internal_error"
|
|
57
|
+
const status = operational ? 500 : 403
|
|
58
|
+
const detail = operational
|
|
59
|
+
? "the configuration store backing this endpoint failed the request"
|
|
60
|
+
: "SCIM provisioning is not enabled for this tenant"
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
64
|
+
status: String(status),
|
|
65
|
+
detail,
|
|
66
|
+
}),
|
|
67
|
+
{
|
|
68
|
+
status,
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/scim+json;charset=utf-8",
|
|
71
|
+
"cache-control": "no-store",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
}
|
|
36
76
|
|
|
37
77
|
function isCallbackPath(pathname: string): boolean {
|
|
38
78
|
return pathname.startsWith("/cb/")
|
|
@@ -89,6 +129,9 @@ export function tenantMiddleware(deps: HttpDeps): MiddlewareHandler<HttpEnv> {
|
|
|
89
129
|
if (url.pathname === "/authorize") {
|
|
90
130
|
return authorizeDirectErrorResponse(resolved.error)
|
|
91
131
|
}
|
|
132
|
+
if (isScimPath(url.pathname)) {
|
|
133
|
+
return scimTenantErrorResponse(resolved.error)
|
|
134
|
+
}
|
|
92
135
|
return tokenEndpointErrorResponse(resolved.error)
|
|
93
136
|
}
|
|
94
137
|
const tenantId = resolved.value
|
|
@@ -101,6 +144,9 @@ export function tenantMiddleware(deps: HttpDeps): MiddlewareHandler<HttpEnv> {
|
|
|
101
144
|
if (url.pathname === "/authorize") {
|
|
102
145
|
return authorizeDirectErrorResponse(err)
|
|
103
146
|
}
|
|
147
|
+
if (isScimPath(url.pathname)) {
|
|
148
|
+
return scimTenantErrorResponse(cfg.error)
|
|
149
|
+
}
|
|
104
150
|
return tokenEndpointErrorResponse(err)
|
|
105
151
|
}
|
|
106
152
|
const custom = deps.buildCustomContext
|
package/src/http/router.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { makeDiscoveryHandler, makeJwksHandler } from "./handlers/metadata"
|
|
|
24
24
|
import { makeMethodRouteHandler } from "./handlers/method-route"
|
|
25
25
|
import { makeParHandler } from "./handlers/par"
|
|
26
26
|
import { makeRegisterHandler } from "./handlers/register"
|
|
27
|
+
import { makeScimHandler } from "./handlers/scim"
|
|
27
28
|
import { makeIntrospectHandler, makeRevokeHandler } from "./handlers/revocation"
|
|
28
29
|
import { makeTokenHandler } from "./handlers/token"
|
|
29
30
|
import { makeUserinfoHandler } from "./handlers/userinfo"
|
|
@@ -71,6 +72,14 @@ export function buildRouter(deps: HttpDeps): Hono<HttpEnv> {
|
|
|
71
72
|
app.get("/end_session", makeEndSessionHandler(deps))
|
|
72
73
|
app.post("/end_session", makeEndSessionHandler(deps))
|
|
73
74
|
|
|
75
|
+
// SCIM 2.0 provisioning. Tenant resolved by the standard middleware;
|
|
76
|
+
// the per-connection bearer token is then verified against that
|
|
77
|
+
// tenant's config in the domain layer (SCIM-AD4). A single `all`
|
|
78
|
+
// mount keeps Hono out of SCIM's routing, which lives in
|
|
79
|
+
// `domain/scim/handle.ts`.
|
|
80
|
+
app.use("/scim/v2/*", tenantMiddleware(deps))
|
|
81
|
+
app.all("/scim/v2/*", makeScimHandler(deps))
|
|
82
|
+
|
|
74
83
|
// RFC 9126 Pushed Authorization Requests.
|
|
75
84
|
app.use("/par", tenantMiddleware(deps))
|
|
76
85
|
app.post("/par", makeParHandler(deps))
|
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,
|
|
@@ -96,6 +98,25 @@ export type {
|
|
|
96
98
|
SigningKey,
|
|
97
99
|
} from "./ports/key-store"
|
|
98
100
|
export type { MethodStore } from "./ports/method-store"
|
|
101
|
+
export type { ScimDirectory } from "./ports/scim-directory"
|
|
102
|
+
export type {
|
|
103
|
+
ScimConfig,
|
|
104
|
+
ScimEnterpriseUser,
|
|
105
|
+
ScimGroupMember,
|
|
106
|
+
ScimGroupPatch,
|
|
107
|
+
ScimGroupQuery,
|
|
108
|
+
ScimGroupRecord,
|
|
109
|
+
ScimGroupWrite,
|
|
110
|
+
ScimFilter,
|
|
111
|
+
ScimFilterAttribute,
|
|
112
|
+
ScimMultiValue,
|
|
113
|
+
ScimName,
|
|
114
|
+
ScimPage,
|
|
115
|
+
ScimUserPatch,
|
|
116
|
+
ScimUserQuery,
|
|
117
|
+
ScimUserRecord,
|
|
118
|
+
ScimUserWrite,
|
|
119
|
+
} from "./types/scim"
|
|
99
120
|
export type {
|
|
100
121
|
ParRecord,
|
|
101
122
|
SessionRecord,
|
|
@@ -239,6 +260,7 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
239
260
|
sessionStore: opts.sessionStore,
|
|
240
261
|
keyStore: opts.keyStore,
|
|
241
262
|
...(opts.methodStore ? { methodStore: opts.methodStore } : {}),
|
|
263
|
+
...(opts.scimDirectory ? { scimDirectory: opts.scimDirectory } : {}),
|
|
242
264
|
...(auditLog ? { auditLog } : {}),
|
|
243
265
|
methodCache,
|
|
244
266
|
stateKeys: opts.stateKeys,
|
|
@@ -246,6 +268,7 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
246
268
|
...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
|
|
247
269
|
resolveTenant: opts.resolveTenant,
|
|
248
270
|
success: opts.success,
|
|
271
|
+
...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
|
|
249
272
|
...(opts.persistUpstreamTokens
|
|
250
273
|
? { persistUpstreamTokens: opts.persistUpstreamTokens }
|
|
251
274
|
: {}),
|
|
@@ -0,0 +1,449 @@
|
|
|
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, resolveSpEntityId } 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 parseVerifiedAssertion(
|
|
85
|
+
profile: NodeSamlProfile,
|
|
86
|
+
):
|
|
87
|
+
| { doc: Document }
|
|
88
|
+
| { failure: MethodResult<SamlSpProperties, SamlSpState> } {
|
|
89
|
+
if (typeof profile.getAssertionXml !== "function") {
|
|
90
|
+
// node-saml v5.1 populates this on a successful verify. Its
|
|
91
|
+
// absence means the library contract changed under us — fail loud
|
|
92
|
+
// rather than skip security checks we now promise.
|
|
93
|
+
return {
|
|
94
|
+
failure: {
|
|
95
|
+
kind: "error",
|
|
96
|
+
error: authError.internalError(
|
|
97
|
+
"saml-sp: node-saml profile exposes no getAssertionXml(); cannot " +
|
|
98
|
+
"perform the Recipient binding check. Refusing to authenticate.",
|
|
99
|
+
),
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return {
|
|
105
|
+
doc: new DOMParser().parseFromString(
|
|
106
|
+
profile.getAssertionXml(),
|
|
107
|
+
"text/xml",
|
|
108
|
+
) as unknown as Document,
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return {
|
|
112
|
+
failure: {
|
|
113
|
+
kind: "error",
|
|
114
|
+
error: authError.internalError(
|
|
115
|
+
"saml-sp: failed to parse the verified assertion",
|
|
116
|
+
e,
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function checkRecipient(
|
|
124
|
+
doc: Document,
|
|
125
|
+
acsUrl: string,
|
|
126
|
+
): MethodResult<SamlSpProperties, SamlSpState> | null {
|
|
127
|
+
// Namespace-agnostic: match by local name regardless of prefix.
|
|
128
|
+
const nodes = doc.getElementsByTagNameNS("*", "SubjectConfirmationData")
|
|
129
|
+
const recipients: string[] = []
|
|
130
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
131
|
+
const r = nodes[i]?.getAttribute("Recipient")
|
|
132
|
+
if (r) recipients.push(r)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (recipients.length === 0) {
|
|
136
|
+
return {
|
|
137
|
+
kind: "denied",
|
|
138
|
+
reason:
|
|
139
|
+
"assertion has no SubjectConfirmationData/@Recipient binding it " +
|
|
140
|
+
"to this ACS",
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!recipients.includes(acsUrl)) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "denied",
|
|
146
|
+
reason: "recipient mismatch: assertion not addressed to this ACS",
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* For IdP-initiated replay dedup: pull the assertion `@ID` and the
|
|
154
|
+
* tightest `NotOnOrAfter` from the **verified** assertion (the same
|
|
155
|
+
* parsed document every other check reads). Returns `null` when the
|
|
156
|
+
* assertion carries no `@ID` — the caller treats that as a fail-loud
|
|
157
|
+
* error rather than skipping replay protection we promised.
|
|
158
|
+
*/
|
|
159
|
+
function extractReplayInfo(
|
|
160
|
+
doc: Document,
|
|
161
|
+
): { assertionId: string; notOnOrAfterMs: number | null } | null {
|
|
162
|
+
const root = doc.documentElement
|
|
163
|
+
const assertionId = root?.getAttribute("ID") ?? ""
|
|
164
|
+
if (!assertionId) return null
|
|
165
|
+
let earliest: number | null = null
|
|
166
|
+
for (const tag of ["Conditions", "SubjectConfirmationData"]) {
|
|
167
|
+
const els = doc.getElementsByTagNameNS("*", tag)
|
|
168
|
+
for (let i = 0; i < els.length; i++) {
|
|
169
|
+
const v = els[i]?.getAttribute("NotOnOrAfter")
|
|
170
|
+
if (!v) continue
|
|
171
|
+
const ms = Date.parse(v)
|
|
172
|
+
if (!Number.isNaN(ms))
|
|
173
|
+
earliest = earliest === null ? ms : Math.min(earliest, ms)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { assertionId, notOnOrAfterMs: earliest }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read the `<AuthnStatement>` facts the host needs but node-saml's
|
|
181
|
+
* `Profile` does not carry (`types.d.ts` exposes `nameID`,
|
|
182
|
+
* `nameIDFormat`, `sessionIndex` and the flattened attributes, and
|
|
183
|
+
* nothing else structural).
|
|
184
|
+
*
|
|
185
|
+
* Read from the **verified** assertion document — never the unsigned
|
|
186
|
+
* outer Response — for the same reason `checkRecipient` is: an
|
|
187
|
+
* attacker-controlled value outside the signature is not a fact.
|
|
188
|
+
*
|
|
189
|
+
* All three are optional in SAML, so every field is best-effort; the
|
|
190
|
+
* caller falls back rather than failing, since none of them is a
|
|
191
|
+
* security control on its own.
|
|
192
|
+
*/
|
|
193
|
+
function extractAuthnStatement(doc: Document): {
|
|
194
|
+
authnInstant?: string
|
|
195
|
+
sessionNotOnOrAfter?: number
|
|
196
|
+
authnContextClassRef?: string
|
|
197
|
+
} {
|
|
198
|
+
const out: {
|
|
199
|
+
authnInstant?: string
|
|
200
|
+
sessionNotOnOrAfter?: number
|
|
201
|
+
authnContextClassRef?: string
|
|
202
|
+
} = {}
|
|
203
|
+
const stmt = doc.getElementsByTagNameNS("*", "AuthnStatement")[0]
|
|
204
|
+
if (!stmt) return out
|
|
205
|
+
|
|
206
|
+
const instant = stmt.getAttribute("AuthnInstant")
|
|
207
|
+
if (instant) out.authnInstant = instant
|
|
208
|
+
|
|
209
|
+
const sessionExpiry = stmt.getAttribute("SessionNotOnOrAfter")
|
|
210
|
+
if (sessionExpiry) {
|
|
211
|
+
const ms = Date.parse(sessionExpiry)
|
|
212
|
+
if (!Number.isNaN(ms)) out.sessionNotOnOrAfter = ms
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ref = stmt
|
|
216
|
+
.getElementsByTagNameNS("*", "AuthnContextClassRef")[0]
|
|
217
|
+
?.textContent?.trim()
|
|
218
|
+
if (ref) out.authnContextClassRef = ref
|
|
219
|
+
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function consumeAssertion(
|
|
224
|
+
ctx: MethodContext<SamlSpState>,
|
|
225
|
+
methodId: string,
|
|
226
|
+
config: SamlSpConfig,
|
|
227
|
+
): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
|
|
228
|
+
// Discriminator: a consumed flow ⇒ SP-initiated; no flow ⇒ an
|
|
229
|
+
// unsolicited IdP-initiated POST (the framework only routes one here
|
|
230
|
+
// when this instance opted in via `unsolicitedCallback`).
|
|
231
|
+
const idpInitiated = ctx.flow === null
|
|
232
|
+
|
|
233
|
+
let spEntityId: string
|
|
234
|
+
let acsUrl: string
|
|
235
|
+
if (idpInitiated) {
|
|
236
|
+
if (!config.idpInitiated) {
|
|
237
|
+
// Fail-closed: should be unreachable (the framework gates on
|
|
238
|
+
// `unsolicitedCallback`, set only when idpInitiated is config'd).
|
|
239
|
+
return {
|
|
240
|
+
kind: "error",
|
|
241
|
+
error: authError.internalError(
|
|
242
|
+
"saml-sp: unsolicited Response reached a method with no " +
|
|
243
|
+
"idpInitiated config",
|
|
244
|
+
),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!ctx.dispatch) {
|
|
248
|
+
return {
|
|
249
|
+
kind: "error",
|
|
250
|
+
error: authError.internalError(
|
|
251
|
+
"saml-sp: IdP-initiated ACS dispatched without issuer context",
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Same derivation as AuthnRequest / metadata — no drift.
|
|
256
|
+
spEntityId = resolveSpEntityId(
|
|
257
|
+
config,
|
|
258
|
+
ctx.dispatch.issuerUrl,
|
|
259
|
+
ctx.tenant.id,
|
|
260
|
+
methodId,
|
|
261
|
+
)
|
|
262
|
+
acsUrl = ctx.dispatch.callbackUrl
|
|
263
|
+
} else {
|
|
264
|
+
const state = ctx.methodState
|
|
265
|
+
if (!state || !state.spEntityId || !state.acsUrl) {
|
|
266
|
+
return {
|
|
267
|
+
kind: "error",
|
|
268
|
+
error: authError.internalError(
|
|
269
|
+
"saml-sp: ACS reached without AuthnRequest method state " +
|
|
270
|
+
"(spEntityId / acsUrl). The flow did not originate from this method.",
|
|
271
|
+
),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
spEntityId = state.spEntityId
|
|
275
|
+
acsUrl = state.acsUrl
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let samlResponse: string | null
|
|
279
|
+
let relayState: string | null
|
|
280
|
+
try {
|
|
281
|
+
const form = new URLSearchParams(await ctx.request.text())
|
|
282
|
+
samlResponse = form.get("SAMLResponse")
|
|
283
|
+
relayState = form.get("RelayState")
|
|
284
|
+
} catch {
|
|
285
|
+
return {
|
|
286
|
+
kind: "error",
|
|
287
|
+
error: authError.internalError("saml-sp: ACS could not read POST body"),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!samlResponse) {
|
|
291
|
+
return { kind: "denied", reason: "missing SAMLResponse" }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Build is a configuration concern (e.g. no signing cert within its
|
|
295
|
+
// validity window, or a rotation gap). A throw here is an operator
|
|
296
|
+
// fault, NOT a user auth failure — surfacing it as `denied` would
|
|
297
|
+
// hide a misconfiguration behind per-user "access denied" noise.
|
|
298
|
+
let saml: ReturnType<typeof buildSamlInstance>
|
|
299
|
+
try {
|
|
300
|
+
saml = buildSamlInstance(
|
|
301
|
+
config,
|
|
302
|
+
{
|
|
303
|
+
spEntityId,
|
|
304
|
+
acsUrl,
|
|
305
|
+
scratch: ctx.methodScratch,
|
|
306
|
+
...(idpInitiated ? { idpInitiated: true } : {}),
|
|
307
|
+
// Encrypted assertions are opt-in per connection. Absent ⇒ no
|
|
308
|
+
// decryptionPvk ⇒ node-saml rejects an EncryptedAssertion.
|
|
309
|
+
...(config.allowEncryptedAssertions && config.decryptionKey
|
|
310
|
+
? { decryptionPvk: config.decryptionKey.privateKeyPem }
|
|
311
|
+
: {}),
|
|
312
|
+
},
|
|
313
|
+
Date.now(),
|
|
314
|
+
)
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return {
|
|
317
|
+
kind: "error",
|
|
318
|
+
error: authError.internalError(
|
|
319
|
+
`saml-sp: cannot construct verifier: ${
|
|
320
|
+
e instanceof Error ? e.message : String(e)
|
|
321
|
+
}`,
|
|
322
|
+
e,
|
|
323
|
+
),
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let profile: NodeSamlProfile | null
|
|
328
|
+
try {
|
|
329
|
+
const result = await saml.validatePostResponseAsync({
|
|
330
|
+
SAMLResponse: samlResponse,
|
|
331
|
+
...(relayState !== null ? { RelayState: relayState } : {}),
|
|
332
|
+
})
|
|
333
|
+
profile = result.profile as NodeSamlProfile | null
|
|
334
|
+
} catch (e) {
|
|
335
|
+
// Every node-saml verification failure (bad/absent signature,
|
|
336
|
+
// signature-wrapping, issuer/audience mismatch, expired
|
|
337
|
+
// conditions, unknown InResponseTo) throws here. These are
|
|
338
|
+
// controlled auth failures, not server faults.
|
|
339
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
340
|
+
// node-saml throws this exact message when an EncryptedAssertion
|
|
341
|
+
// arrives but no decryptionPvk is configured. Give the operator a
|
|
342
|
+
// signal that the *connection* is mis/under-configured rather than
|
|
343
|
+
// a generic "assertion rejected".
|
|
344
|
+
if (!config.allowEncryptedAssertions && msg.includes("No decryption key")) {
|
|
345
|
+
return {
|
|
346
|
+
kind: "denied",
|
|
347
|
+
reason:
|
|
348
|
+
"encrypted assertion received but encrypted assertions are not " +
|
|
349
|
+
"enabled for this SAML connection (set allowEncryptedAssertions " +
|
|
350
|
+
"+ decryptionKey)",
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { kind: "denied", reason: `assertion rejected: ${msg}` }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!profile || !profile.nameID) {
|
|
357
|
+
return { kind: "denied", reason: "assertion produced no usable subject" }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// One parse of the verified assertion, shared by every check below
|
|
361
|
+
// (Recipient binding, IdP-init replay dedup, AuthnStatement facts) —
|
|
362
|
+
// they all read the same signed bytes, so parsing once is both
|
|
363
|
+
// cheaper and impossible to accidentally diverge.
|
|
364
|
+
const parsed = parseVerifiedAssertion(profile)
|
|
365
|
+
if ("failure" in parsed) return parsed.failure
|
|
366
|
+
const assertionDoc = parsed.doc
|
|
367
|
+
|
|
368
|
+
// Gauntlet item 6 — node-saml does not enforce @Recipient; we do,
|
|
369
|
+
// against the exact ACS URL the IdP saw (committed for SP-init,
|
|
370
|
+
// derived for IdP-init — identical value either way).
|
|
371
|
+
const recipientFailure = checkRecipient(assertionDoc, acsUrl)
|
|
372
|
+
if (recipientFailure) return recipientFailure
|
|
373
|
+
|
|
374
|
+
// IdP-init replay: no InResponseTo single-use to lean on, so dedup
|
|
375
|
+
// the signed assertion's @ID. SP-init does not need this (the
|
|
376
|
+
// request id is already single-use).
|
|
377
|
+
if (idpInitiated) {
|
|
378
|
+
const replay = extractReplayInfo(assertionDoc)
|
|
379
|
+
if (!replay) {
|
|
380
|
+
return {
|
|
381
|
+
kind: "error",
|
|
382
|
+
error: authError.internalError(
|
|
383
|
+
"saml-sp: cannot read assertion @ID for IdP-initiated replay " +
|
|
384
|
+
"protection. Refusing to authenticate.",
|
|
385
|
+
),
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const key = `idp-replay:${replay.assertionId}`
|
|
389
|
+
const seen = await ctx.methodScratch.get(key)
|
|
390
|
+
if (seen.ok) {
|
|
391
|
+
return {
|
|
392
|
+
kind: "denied",
|
|
393
|
+
reason: "assertion replay detected (assertion ID already seen)",
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const skewMs = (config.clockSkewSeconds ?? 60) * 1000
|
|
397
|
+
const now = Date.now()
|
|
398
|
+
const horizon =
|
|
399
|
+
replay.notOnOrAfterMs !== null
|
|
400
|
+
? replay.notOnOrAfterMs - now + skewMs
|
|
401
|
+
: 10 * 60_000
|
|
402
|
+
// Clamp: never below the skew window, never an unbounded entry.
|
|
403
|
+
const ttlMs = Math.min(Math.max(horizon, skewMs, 60_000), 24 * 60 * 60_000)
|
|
404
|
+
const recorded = await ctx.methodScratch.put(key, "1", ttlMs)
|
|
405
|
+
if (!recorded.ok) {
|
|
406
|
+
// The dedup store is unavailable — failing open would allow
|
|
407
|
+
// replay. Fail closed.
|
|
408
|
+
return {
|
|
409
|
+
kind: "error",
|
|
410
|
+
error: authError.internalError(
|
|
411
|
+
"saml-sp: could not record assertion ID for replay protection",
|
|
412
|
+
),
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const authnStatement = extractAuthnStatement(assertionDoc)
|
|
418
|
+
|
|
419
|
+
const verified: VerifiedProfile = {
|
|
420
|
+
nameID: profile.nameID,
|
|
421
|
+
nameIDFormat: profile.nameIDFormat ?? "",
|
|
422
|
+
...(profile.sessionIndex !== undefined
|
|
423
|
+
? { sessionIndex: profile.sessionIndex }
|
|
424
|
+
: {}),
|
|
425
|
+
...authnStatement,
|
|
426
|
+
attributes: profile.attributes ?? {},
|
|
427
|
+
responseXml: profile.getSamlResponseXml?.() ?? "",
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const mapped = mapProfile(verified, config.attributeMapping)
|
|
431
|
+
if ("error" in mapped) {
|
|
432
|
+
return { kind: "denied", reason: mapped.error }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
kind: "success",
|
|
437
|
+
providerSubject: mapped.providerSubject,
|
|
438
|
+
properties: mapped.properties,
|
|
439
|
+
...(idpInitiated && config.idpInitiated
|
|
440
|
+
? {
|
|
441
|
+
unsolicitedBinding: {
|
|
442
|
+
clientId: config.idpInitiated.defaultClientId,
|
|
443
|
+
redirectUri: config.idpInitiated.defaultRedirectUri,
|
|
444
|
+
scopes: config.idpInitiated.defaultScopes ?? [],
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
: {}),
|
|
448
|
+
}
|
|
449
|
+
}
|