@_mustachio/openauth 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/adapters/d1/index.js +18 -9
- package/dist/esm/adapters/d1/migrations.js +7 -2
- package/dist/esm/adapters/d1/session-store.js +39 -0
- package/dist/esm/adapters/d1/session.js +2 -2
- package/dist/esm/adapters/durable-object/index.js +4 -0
- package/dist/esm/adapters/durable-object/session-store.js +42 -0
- package/dist/esm/adapters/dynamo/index.js +24 -7
- package/dist/esm/adapters/dynamo/session-store.js +49 -0
- package/dist/esm/adapters/kms/index.js +6 -2
- package/dist/esm/adapters/kv/index.js +6 -2
- package/dist/esm/adapters/memory/index.js +24 -7
- package/dist/esm/adapters/memory/session-store.js +23 -0
- package/dist/esm/adapters/postgres/executor.js +2 -2
- package/dist/esm/adapters/postgres/index.js +33 -10
- package/dist/esm/adapters/postgres/migrations.js +8 -2
- package/dist/esm/adapters/postgres/session-store.js +42 -0
- package/dist/esm/client.js +27 -27
- package/dist/esm/domain/authorize.js +2 -2
- package/dist/esm/domain/callback.js +121 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +2 -2
- package/dist/esm/domain/dpop.js +4 -4
- package/dist/esm/domain/id-token.js +5 -5
- package/dist/esm/domain/jwt.js +4 -4
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +80 -1
- package/dist/esm/domain/par.js +2 -2
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token.js +5 -5
- package/dist/esm/error.js +8 -8
- package/dist/esm/http/cookies.js +19 -2
- package/dist/esm/http/errors.js +4 -4
- package/dist/esm/http/handlers/authorize.js +3 -19
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/metadata.js +2 -2
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +31 -6
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/http/schemas/authorize.js +2 -2
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +34 -32
- package/dist/esm/methods/oauth2-factory.js +2 -2
- package/dist/esm/methods/password-hash.js +2 -2
- package/dist/esm/methods/providers/index.js +30 -14
- package/dist/esm/methods/saml-sp/acs.js +232 -0
- package/dist/esm/methods/saml-sp/attributes.js +77 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
- package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
- package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
- package/dist/esm/methods/saml-sp/factory.js +81 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +71 -0
- package/dist/esm/methods/saml-sp/method.js +29 -0
- package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
- package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
- package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
- package/dist/esm/methods/saml-sp/sls.js +154 -0
- package/dist/esm/methods/saml-sp/types.js +0 -0
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/types/scim.js +0 -0
- package/dist/esm/ui/forms.js +3 -3
- package/dist/esm/ui/index.js +8 -5
- package/dist/esm/ui/picker.js +2 -2
- package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
- package/dist/types/adapters/d1/session-store.d.ts +3 -0
- package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
- package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
- package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
- package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
- package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/session-store.d.ts +3 -0
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
- package/dist/types/adapters/postgres/session-store.d.ts +3 -0
- package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
- package/dist/types/domain/callback.d.ts +16 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +57 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +6 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/cookies.d.ts +9 -1
- package/dist/types/http/cookies.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts +4 -0
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/factory.d.ts +920 -0
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/index.d.ts +24 -0
- package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/method.d.ts +17 -0
- package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/sls.d.ts +4 -0
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/types.d.ts +299 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +18 -5
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts +23 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +83 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +109 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/adapters/d1/migrations.ts +5 -0
- package/src/adapters/d1/session-store.ts +68 -0
- package/src/adapters/durable-object/session-store.ts +61 -0
- package/src/adapters/dynamo/session-store.ts +63 -0
- package/src/adapters/memory/session-store.ts +39 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/domain/callback.ts +215 -7
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +190 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +9 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -22
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +72 -2
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +23 -0
- package/src/methods/saml-sp/acs.ts +449 -0
- package/src/methods/saml-sp/attributes.ts +146 -0
- package/src/methods/saml-sp/authnrequest.ts +129 -0
- package/src/methods/saml-sp/cache-provider.ts +76 -0
- package/src/methods/saml-sp/cert-rotation.ts +34 -0
- package/src/methods/saml-sp/factory.ts +139 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +215 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
- package/src/methods/saml-sp/saml-instance.ts +210 -0
- package/src/methods/saml-sp/slo-initiate.ts +180 -0
- package/src/methods/saml-sp/sls.ts +307 -0
- package/src/methods/saml-sp/types.ts +298 -0
- package/src/ports/CONSISTENCY.md +52 -24
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +24 -0
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +86 -0
- package/src/types/method.ts +110 -0
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +10 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP-initiated Single Logout — the outbound half.
|
|
3
|
+
*
|
|
4
|
+
* `POST /m/<methodId>/logout` (public, gated on `idp.sloUrl` like
|
|
5
|
+
* `/sls`). The **host** invokes this from its own logout UX for the
|
|
6
|
+
* **already-authenticated** subject, supplying the subject's SAML
|
|
7
|
+
* `NameID` (and, ideally, `SessionIndex`) — the values it received in
|
|
8
|
+
* `SamlSpProperties` at login. The library does not persist the
|
|
9
|
+
* NameID↔subject↔session mapping (the host owns it; see
|
|
10
|
+
* `ARCHITECTURE.md` §"onLogout"), so the host is the only party that
|
|
11
|
+
* can name who is logging out.
|
|
12
|
+
*
|
|
13
|
+
* Host contract / security:
|
|
14
|
+
* - This route only **propagates** logout to the upstream IdP. It
|
|
15
|
+
* does **not** revoke library tokens — OIDC session/token
|
|
16
|
+
* termination is `/end_session`'s job and is deliberately *not*
|
|
17
|
+
* auto-bridged. A complete host logout flow calls both.
|
|
18
|
+
* - It is `POST` (not `GET`) to keep a signed `LogoutRequest` off
|
|
19
|
+
* drive-by navigations / prefetch. It is still anonymous at the
|
|
20
|
+
* library boundary: the host MUST only render/trigger it for the
|
|
21
|
+
* authenticated subject, behind the host's own CSRF protection,
|
|
22
|
+
* with that subject's own `NameID`. A forced-logout via CSRF is a
|
|
23
|
+
* DoS-class risk the host owns (it owns the session); the library
|
|
24
|
+
* cannot authenticate the caller without owning a session it
|
|
25
|
+
* deliberately does not.
|
|
26
|
+
*
|
|
27
|
+
* The IdP processes the `LogoutRequest` and returns a `LogoutResponse`
|
|
28
|
+
* to `/sls` (the response leg — see `sls.ts`).
|
|
29
|
+
*/
|
|
30
|
+
import { authError } from "../../types/error"
|
|
31
|
+
import type { MethodContext, MethodResult } from "../../types/method"
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
buildSamlInstance,
|
|
35
|
+
resolveSpEntityId,
|
|
36
|
+
NAME_ID_FORMAT_URN,
|
|
37
|
+
} from "./saml-instance"
|
|
38
|
+
import type {
|
|
39
|
+
SamlNameIdFormat,
|
|
40
|
+
SamlSpConfig,
|
|
41
|
+
SamlSpProperties,
|
|
42
|
+
SamlSpState,
|
|
43
|
+
} from "./types"
|
|
44
|
+
|
|
45
|
+
export async function initiateSpLogout(
|
|
46
|
+
ctx: MethodContext<SamlSpState>,
|
|
47
|
+
methodId: string,
|
|
48
|
+
config: SamlSpConfig,
|
|
49
|
+
): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
|
|
50
|
+
if (!ctx.dispatch) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "error",
|
|
53
|
+
error: authError.internalError(
|
|
54
|
+
"saml-sp: /logout dispatched without issuer context (ctx.dispatch is null)",
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!config.idp.sloUrl) {
|
|
59
|
+
// Unreachable: /logout is only public when sloUrl is set.
|
|
60
|
+
return {
|
|
61
|
+
kind: "error",
|
|
62
|
+
error: authError.internalError(
|
|
63
|
+
"saml-sp: /logout reached with no idp.sloUrl configured",
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let nameId: string | null
|
|
69
|
+
let sessionIndex: string | null
|
|
70
|
+
let relayState: string | null
|
|
71
|
+
let nameIdFormatParam: string | null
|
|
72
|
+
try {
|
|
73
|
+
const form = new URLSearchParams(await ctx.request.text())
|
|
74
|
+
nameId = form.get("nameId")
|
|
75
|
+
sessionIndex = form.get("sessionIndex")
|
|
76
|
+
relayState = form.get("relayState")
|
|
77
|
+
nameIdFormatParam = form.get("nameIdFormat")
|
|
78
|
+
} catch {
|
|
79
|
+
return {
|
|
80
|
+
kind: "error",
|
|
81
|
+
error: authError.internalError(
|
|
82
|
+
"saml-sp: /logout could not read POST body",
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!nameId) {
|
|
88
|
+
// The host must supply the subject's SAML NameID — the library
|
|
89
|
+
// cannot derive it (it never persisted the mapping).
|
|
90
|
+
return { kind: "denied", reason: "missing nameId" }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// NameID format: explicit param wins; else the connection's
|
|
94
|
+
// configured format; else persistent (the SSO default). The param is
|
|
95
|
+
// a friendly key (same vocabulary as `config.idp.nameIdFormat`), not
|
|
96
|
+
// a raw URN — validate it rather than passing an arbitrary
|
|
97
|
+
// host-supplied string straight into the LogoutRequest, where a
|
|
98
|
+
// typo'd format silently produces a cryptic IdP-side rejection.
|
|
99
|
+
if (
|
|
100
|
+
nameIdFormatParam !== null &&
|
|
101
|
+
!(nameIdFormatParam in NAME_ID_FORMAT_URN)
|
|
102
|
+
) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "denied",
|
|
105
|
+
reason:
|
|
106
|
+
`unrecognized nameIdFormat "${nameIdFormatParam}" — expected one ` +
|
|
107
|
+
`of: ${Object.keys(NAME_ID_FORMAT_URN).join(", ")}`,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const nameIDFormat =
|
|
111
|
+
nameIdFormatParam !== null
|
|
112
|
+
? NAME_ID_FORMAT_URN[nameIdFormatParam as SamlNameIdFormat]
|
|
113
|
+
: config.idp.nameIdFormat !== undefined
|
|
114
|
+
? NAME_ID_FORMAT_URN[config.idp.nameIdFormat]
|
|
115
|
+
: NAME_ID_FORMAT_URN.persistent
|
|
116
|
+
|
|
117
|
+
const spEntityId = resolveSpEntityId(
|
|
118
|
+
config,
|
|
119
|
+
ctx.dispatch.issuerUrl,
|
|
120
|
+
ctx.tenant.id,
|
|
121
|
+
methodId,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
let redirectUrl: string
|
|
125
|
+
try {
|
|
126
|
+
const saml = buildSamlInstance(
|
|
127
|
+
config,
|
|
128
|
+
{
|
|
129
|
+
spEntityId,
|
|
130
|
+
acsUrl: ctx.dispatch.callbackUrl,
|
|
131
|
+
scratch: ctx.methodScratch,
|
|
132
|
+
logoutUrl: config.idp.sloUrl,
|
|
133
|
+
logout: true,
|
|
134
|
+
// Sign the LogoutRequest whenever a per-connection SP key is
|
|
135
|
+
// configured — most IdPs require a signed SP-initiated logout.
|
|
136
|
+
...(config.signingKey
|
|
137
|
+
? {
|
|
138
|
+
signing: {
|
|
139
|
+
privateKeyPem: config.signingKey.privateKeyPem,
|
|
140
|
+
certPem: config.signingKey.certPem,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
},
|
|
145
|
+
Date.now(),
|
|
146
|
+
)
|
|
147
|
+
redirectUrl = await saml.getLogoutUrlAsync(
|
|
148
|
+
// node-saml's Profile shape; structurally compatible for request
|
|
149
|
+
// generation (it reads nameID / nameIDFormat / sessionIndex).
|
|
150
|
+
{
|
|
151
|
+
nameID: nameId,
|
|
152
|
+
nameIDFormat: nameIDFormat,
|
|
153
|
+
...(sessionIndex !== null ? { sessionIndex } : {}),
|
|
154
|
+
} as never,
|
|
155
|
+
relayState ?? "",
|
|
156
|
+
{},
|
|
157
|
+
)
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return {
|
|
160
|
+
kind: "error",
|
|
161
|
+
error: authError.internalError(
|
|
162
|
+
`saml-sp: failed to build LogoutRequest: ${
|
|
163
|
+
e instanceof Error ? e.message : String(e)
|
|
164
|
+
}`,
|
|
165
|
+
e,
|
|
166
|
+
),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Pure protocol propagation — no `logout` side effect (the host
|
|
171
|
+
// already tore down its session and, separately, calls /end_session
|
|
172
|
+
// for OIDC token revocation).
|
|
173
|
+
return {
|
|
174
|
+
kind: "challenge",
|
|
175
|
+
response: new Response(null, {
|
|
176
|
+
status: 302,
|
|
177
|
+
headers: { location: redirectUrl },
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single Logout Service — the inbound front-channel logout half.
|
|
3
|
+
*
|
|
4
|
+
* Mounted as a **public** (anonymous, flowless) route at
|
|
5
|
+
* `GET|POST /m/<methodId>/sls` (declared in `AuthMethod.publicRoutes`
|
|
6
|
+
* only when `SamlSpConfig.idp.sloUrl` is configured — advertising an
|
|
7
|
+
* SLS we cannot complete would break interop, mirroring how
|
|
8
|
+
* `unsolicitedCallback` is gated on `idpInitiated`).
|
|
9
|
+
*
|
|
10
|
+
* Authenticity here is **cryptographic, not cookie-based** — exactly
|
|
11
|
+
* the SAML front-channel SLO model. node-saml verifies the inbound
|
|
12
|
+
* message's XML-DSig against the pinned IdP cert (`SAML-AD1` — we do
|
|
13
|
+
* not reimplement signature checking):
|
|
14
|
+
*
|
|
15
|
+
* - **`SAMLRequest`** = an IdP-initiated `LogoutRequest`. Verify it,
|
|
16
|
+
* emit a signed `LogoutResponse` redirect back to the IdP's SLO
|
|
17
|
+
* endpoint, and return `MethodResult.challenge` carrying the
|
|
18
|
+
* verified `logout` intent so the framework fires
|
|
19
|
+
* `IdPOptions.onLogout` (host session teardown + optional
|
|
20
|
+
* `revokeAllForSubject`). The method stays port-free; the
|
|
21
|
+
* privileged side effect runs in the framework's public-route
|
|
22
|
+
* pipeline (see `ARCHITECTURE.md` §"onLogout + challenge.logout").
|
|
23
|
+
* - **`SAMLResponse`** = the IdP's `LogoutResponse` to an
|
|
24
|
+
* SP-initiated `LogoutRequest`. Validated and acknowledged here;
|
|
25
|
+
* the SP-initiated *send* path + post-logout redirect lands in the
|
|
26
|
+
* next increment.
|
|
27
|
+
*
|
|
28
|
+
* Verification failures are controlled `denied` (bad/absent signature,
|
|
29
|
+
* unknown issuer, malformed message) — the same classification
|
|
30
|
+
* `acs.ts` uses. Misconfiguration / infra faults are `error`.
|
|
31
|
+
*/
|
|
32
|
+
import { authError } from "../../types/error"
|
|
33
|
+
import type { MethodContext, MethodResult } from "../../types/method"
|
|
34
|
+
import { isErr } from "../../types/result"
|
|
35
|
+
|
|
36
|
+
import { buildSamlInstance, resolveSpEntityId } from "./saml-instance"
|
|
37
|
+
import type { SamlSpConfig, SamlSpProperties, SamlSpState } from "./types"
|
|
38
|
+
|
|
39
|
+
/** Minimal view of node-saml's `Profile` — never leaked publicly. */
|
|
40
|
+
type NodeSamlProfile = {
|
|
41
|
+
nameID?: string
|
|
42
|
+
sessionIndex?: string
|
|
43
|
+
/** The inbound message's `ID` — used for front-channel replay dedup. */
|
|
44
|
+
ID?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Replay-dedup horizon for a verified `LogoutRequest`. A SAML
|
|
49
|
+
* `LogoutRequest` usually carries no `NotOnOrAfter`, so unlike the
|
|
50
|
+
* assertion path there is no message-supplied expiry to track — a
|
|
51
|
+
* fixed, clamped window is sufficient (logout is idempotent; this only
|
|
52
|
+
* suppresses a signed-message replay storm).
|
|
53
|
+
*/
|
|
54
|
+
const SLO_REPLAY_HORIZON_MS = 10 * 60_000
|
|
55
|
+
|
|
56
|
+
export async function consumeSls(
|
|
57
|
+
ctx: MethodContext<SamlSpState>,
|
|
58
|
+
methodId: string,
|
|
59
|
+
config: SamlSpConfig,
|
|
60
|
+
): Promise<MethodResult<SamlSpProperties, SamlSpState>> {
|
|
61
|
+
// Public route: no flow, but the HTTP layer supplies dispatch so we
|
|
62
|
+
// can derive the stable SP entityID (same derivation as AuthnRequest
|
|
63
|
+
// / ACS / metadata — no drift).
|
|
64
|
+
if (!ctx.dispatch) {
|
|
65
|
+
return {
|
|
66
|
+
kind: "error",
|
|
67
|
+
error: authError.internalError(
|
|
68
|
+
"saml-sp: /sls dispatched without issuer context (ctx.dispatch is null)",
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!config.idp.sloUrl) {
|
|
73
|
+
// Should be unreachable: /sls is only public when sloUrl is set.
|
|
74
|
+
return {
|
|
75
|
+
kind: "error",
|
|
76
|
+
error: authError.internalError(
|
|
77
|
+
"saml-sp: /sls reached with no idp.sloUrl configured",
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spEntityId = resolveSpEntityId(
|
|
83
|
+
config,
|
|
84
|
+
ctx.dispatch.issuerUrl,
|
|
85
|
+
ctx.tenant.id,
|
|
86
|
+
methodId,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// Read both bindings: HTTP-Redirect (GET, signed query string) and
|
|
90
|
+
// HTTP-POST (form body). The raw query string is required to verify a
|
|
91
|
+
// redirect-binding signature (it is computed over the exact bytes).
|
|
92
|
+
const url = new URL(ctx.request.url)
|
|
93
|
+
const originalQuery = url.search.replace(/^\?/, "")
|
|
94
|
+
let samlRequest: string | null
|
|
95
|
+
let samlResponse: string | null
|
|
96
|
+
let relayState: string | null
|
|
97
|
+
const isPost = ctx.request.method.toUpperCase() === "POST"
|
|
98
|
+
let postForm: URLSearchParams | null = null
|
|
99
|
+
try {
|
|
100
|
+
if (isPost) {
|
|
101
|
+
postForm = new URLSearchParams(await ctx.request.text())
|
|
102
|
+
samlRequest = postForm.get("SAMLRequest")
|
|
103
|
+
samlResponse = postForm.get("SAMLResponse")
|
|
104
|
+
relayState = postForm.get("RelayState")
|
|
105
|
+
} else {
|
|
106
|
+
samlRequest = url.searchParams.get("SAMLRequest")
|
|
107
|
+
samlResponse = url.searchParams.get("SAMLResponse")
|
|
108
|
+
relayState = url.searchParams.get("RelayState")
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
return {
|
|
112
|
+
kind: "error",
|
|
113
|
+
error: authError.internalError("saml-sp: /sls could not read request"),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!samlRequest && !samlResponse) {
|
|
118
|
+
return { kind: "denied", reason: "missing SAMLRequest / SAMLResponse" }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Building the verifier is a configuration concern (e.g. no IdP
|
|
122
|
+
// signing cert within its validity window) — an operator fault, not a
|
|
123
|
+
// per-user auth failure. Sign logout messages whenever a per-
|
|
124
|
+
// connection SP key is configured (decoupled from `signAuthnRequest`,
|
|
125
|
+
// which is specifically about the AuthnRequest).
|
|
126
|
+
let saml: ReturnType<typeof buildSamlInstance>
|
|
127
|
+
try {
|
|
128
|
+
saml = buildSamlInstance(
|
|
129
|
+
config,
|
|
130
|
+
{
|
|
131
|
+
spEntityId,
|
|
132
|
+
acsUrl: ctx.dispatch.callbackUrl,
|
|
133
|
+
scratch: ctx.methodScratch,
|
|
134
|
+
logoutUrl: config.idp.sloUrl,
|
|
135
|
+
logout: true,
|
|
136
|
+
...(config.signingKey
|
|
137
|
+
? {
|
|
138
|
+
signing: {
|
|
139
|
+
privateKeyPem: config.signingKey.privateKeyPem,
|
|
140
|
+
certPem: config.signingKey.certPem,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
: {}),
|
|
144
|
+
},
|
|
145
|
+
Date.now(),
|
|
146
|
+
)
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return {
|
|
149
|
+
kind: "error",
|
|
150
|
+
error: authError.internalError(
|
|
151
|
+
`saml-sp: cannot construct logout verifier: ${
|
|
152
|
+
e instanceof Error ? e.message : String(e)
|
|
153
|
+
}`,
|
|
154
|
+
e,
|
|
155
|
+
),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- IdP-initiated LogoutRequest (the Phase 3 deliverable) --------
|
|
160
|
+
if (samlRequest) {
|
|
161
|
+
let profile: NodeSamlProfile | null
|
|
162
|
+
try {
|
|
163
|
+
const res = isPost
|
|
164
|
+
? await saml.validatePostRequestAsync({
|
|
165
|
+
SAMLRequest: samlRequest,
|
|
166
|
+
...(relayState !== null ? { RelayState: relayState } : {}),
|
|
167
|
+
})
|
|
168
|
+
: await saml.validateRedirectAsync(
|
|
169
|
+
Object.fromEntries(url.searchParams) as Record<string, string>,
|
|
170
|
+
originalQuery,
|
|
171
|
+
)
|
|
172
|
+
profile = res.profile as NodeSamlProfile | null
|
|
173
|
+
} catch (e) {
|
|
174
|
+
// Bad/absent signature, unknown issuer, malformed LogoutRequest —
|
|
175
|
+
// controlled, not a server fault. Same posture as the ACS.
|
|
176
|
+
return {
|
|
177
|
+
kind: "denied",
|
|
178
|
+
reason: `logout request rejected: ${
|
|
179
|
+
e instanceof Error ? e.message : String(e)
|
|
180
|
+
}`,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!profile) {
|
|
184
|
+
return { kind: "denied", reason: "logout request produced no profile" }
|
|
185
|
+
}
|
|
186
|
+
// Fail-closed: the replay guard below is load-bearing (it stops a
|
|
187
|
+
// captured validly-signed LogoutRequest from repeatedly driving
|
|
188
|
+
// onLogout/revokeAllForSubject). node-saml v5.1.0 already throws
|
|
189
|
+
// before returning a profile when `@ID` is absent, so this is
|
|
190
|
+
// unreachable for a conformant message — but asserting it here
|
|
191
|
+
// makes the documented fail-closed posture true in code rather
|
|
192
|
+
// than silently dependent on an upstream library invariant
|
|
193
|
+
// (mirrors the IdP-init assertion-replay handling in acs.ts).
|
|
194
|
+
if (!profile.ID) {
|
|
195
|
+
return {
|
|
196
|
+
kind: "error",
|
|
197
|
+
error: authError.internalError(
|
|
198
|
+
"saml-sp: LogoutRequest carries no @ID — front-channel replay " +
|
|
199
|
+
"protection cannot be established. Refusing to process.",
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Front-channel replay dedup on the verified LogoutRequest @ID.
|
|
205
|
+
// Logout is idempotent so the blast radius is low, but a replayed
|
|
206
|
+
// signed message should not repeatedly drive revocation.
|
|
207
|
+
{
|
|
208
|
+
const key = `slo-replay:${profile.ID}`
|
|
209
|
+
const seen = await ctx.methodScratch.get(key)
|
|
210
|
+
if (seen.ok) {
|
|
211
|
+
return {
|
|
212
|
+
kind: "denied",
|
|
213
|
+
reason: "logout request replay detected (request ID already seen)",
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const recorded = await ctx.methodScratch.put(
|
|
217
|
+
key,
|
|
218
|
+
"1",
|
|
219
|
+
SLO_REPLAY_HORIZON_MS,
|
|
220
|
+
)
|
|
221
|
+
if (isErr(recorded)) {
|
|
222
|
+
return {
|
|
223
|
+
kind: "error",
|
|
224
|
+
error: authError.internalError(
|
|
225
|
+
"saml-sp: could not record LogoutRequest ID for replay protection",
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Emit the signed LogoutResponse redirect back to the IdP's SLO
|
|
232
|
+
// endpoint (success status — we always honour a validly-signed
|
|
233
|
+
// logout). node-saml uses `logoutUrl` (set above) as the target and
|
|
234
|
+
// echoes `InResponseTo` from the request profile.
|
|
235
|
+
let logoutResponseUrl: string
|
|
236
|
+
try {
|
|
237
|
+
logoutResponseUrl = await saml.getLogoutResponseUrlAsync(
|
|
238
|
+
// node-saml's Profile shape; our minimal view is structurally
|
|
239
|
+
// compatible for response generation (it reads ID / issuer).
|
|
240
|
+
profile as never,
|
|
241
|
+
relayState ?? "",
|
|
242
|
+
{},
|
|
243
|
+
true,
|
|
244
|
+
)
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return {
|
|
247
|
+
kind: "error",
|
|
248
|
+
error: authError.internalError(
|
|
249
|
+
`saml-sp: failed to build LogoutResponse: ${
|
|
250
|
+
e instanceof Error ? e.message : String(e)
|
|
251
|
+
}`,
|
|
252
|
+
e,
|
|
253
|
+
),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
kind: "challenge",
|
|
259
|
+
response: new Response(null, {
|
|
260
|
+
status: 302,
|
|
261
|
+
headers: { location: logoutResponseUrl },
|
|
262
|
+
}),
|
|
263
|
+
// Triggers the framework's onLogout side effect (host teardown +
|
|
264
|
+
// optional revokeAllForSubject) before the redirect is returned.
|
|
265
|
+
logout: {
|
|
266
|
+
...(profile.nameID !== undefined ? { nameId: profile.nameID } : {}),
|
|
267
|
+
...(profile.sessionIndex !== undefined
|
|
268
|
+
? { sessionIndex: profile.sessionIndex }
|
|
269
|
+
: {}),
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- IdP LogoutResponse (SP-initiated completion) ----------------
|
|
275
|
+
// The SP-initiated *send* path + post-logout redirect lands in the
|
|
276
|
+
// next increment; here we validate the IdP confirmed logout and
|
|
277
|
+
// acknowledge. No `logout` side effect — SP-initiated revocation
|
|
278
|
+
// happens at the trigger, not on the return leg.
|
|
279
|
+
try {
|
|
280
|
+
if (isPost) {
|
|
281
|
+
await saml.validatePostResponseAsync({
|
|
282
|
+
SAMLResponse: samlResponse as string,
|
|
283
|
+
...(relayState !== null ? { RelayState: relayState } : {}),
|
|
284
|
+
})
|
|
285
|
+
} else {
|
|
286
|
+
await saml.validateRedirectAsync(
|
|
287
|
+
Object.fromEntries(url.searchParams) as Record<string, string>,
|
|
288
|
+
originalQuery,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {
|
|
292
|
+
return {
|
|
293
|
+
kind: "denied",
|
|
294
|
+
reason: `logout response rejected: ${
|
|
295
|
+
e instanceof Error ? e.message : String(e)
|
|
296
|
+
}`,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
kind: "challenge",
|
|
302
|
+
response: new Response("Logged out.", {
|
|
303
|
+
status: 200,
|
|
304
|
+
headers: { "content-type": "text/plain" },
|
|
305
|
+
}),
|
|
306
|
+
}
|
|
307
|
+
}
|