@_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
|
@@ -0,0 +1,306 @@
|
|
|
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, deriveSpEntityId } 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 = deriveSpEntityId(
|
|
83
|
+
ctx.dispatch.issuerUrl,
|
|
84
|
+
ctx.tenant.id,
|
|
85
|
+
methodId,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Read both bindings: HTTP-Redirect (GET, signed query string) and
|
|
89
|
+
// HTTP-POST (form body). The raw query string is required to verify a
|
|
90
|
+
// redirect-binding signature (it is computed over the exact bytes).
|
|
91
|
+
const url = new URL(ctx.request.url)
|
|
92
|
+
const originalQuery = url.search.replace(/^\?/, "")
|
|
93
|
+
let samlRequest: string | null
|
|
94
|
+
let samlResponse: string | null
|
|
95
|
+
let relayState: string | null
|
|
96
|
+
const isPost = ctx.request.method.toUpperCase() === "POST"
|
|
97
|
+
let postForm: URLSearchParams | null = null
|
|
98
|
+
try {
|
|
99
|
+
if (isPost) {
|
|
100
|
+
postForm = new URLSearchParams(await ctx.request.text())
|
|
101
|
+
samlRequest = postForm.get("SAMLRequest")
|
|
102
|
+
samlResponse = postForm.get("SAMLResponse")
|
|
103
|
+
relayState = postForm.get("RelayState")
|
|
104
|
+
} else {
|
|
105
|
+
samlRequest = url.searchParams.get("SAMLRequest")
|
|
106
|
+
samlResponse = url.searchParams.get("SAMLResponse")
|
|
107
|
+
relayState = url.searchParams.get("RelayState")
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
kind: "error",
|
|
112
|
+
error: authError.internalError("saml-sp: /sls could not read request"),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!samlRequest && !samlResponse) {
|
|
117
|
+
return { kind: "denied", reason: "missing SAMLRequest / SAMLResponse" }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Building the verifier is a configuration concern (e.g. no IdP
|
|
121
|
+
// signing cert within its validity window) — an operator fault, not a
|
|
122
|
+
// per-user auth failure. Sign logout messages whenever a per-
|
|
123
|
+
// connection SP key is configured (decoupled from `signAuthnRequest`,
|
|
124
|
+
// which is specifically about the AuthnRequest).
|
|
125
|
+
let saml: ReturnType<typeof buildSamlInstance>
|
|
126
|
+
try {
|
|
127
|
+
saml = buildSamlInstance(
|
|
128
|
+
config,
|
|
129
|
+
{
|
|
130
|
+
spEntityId,
|
|
131
|
+
acsUrl: ctx.dispatch.callbackUrl,
|
|
132
|
+
scratch: ctx.methodScratch,
|
|
133
|
+
logoutUrl: config.idp.sloUrl,
|
|
134
|
+
logout: true,
|
|
135
|
+
...(config.signingKey
|
|
136
|
+
? {
|
|
137
|
+
signing: {
|
|
138
|
+
privateKeyPem: config.signingKey.privateKeyPem,
|
|
139
|
+
certPem: config.signingKey.certPem,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
: {}),
|
|
143
|
+
},
|
|
144
|
+
Date.now(),
|
|
145
|
+
)
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return {
|
|
148
|
+
kind: "error",
|
|
149
|
+
error: authError.internalError(
|
|
150
|
+
`saml-sp: cannot construct logout verifier: ${
|
|
151
|
+
e instanceof Error ? e.message : String(e)
|
|
152
|
+
}`,
|
|
153
|
+
e,
|
|
154
|
+
),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- IdP-initiated LogoutRequest (the Phase 3 deliverable) --------
|
|
159
|
+
if (samlRequest) {
|
|
160
|
+
let profile: NodeSamlProfile | null
|
|
161
|
+
try {
|
|
162
|
+
const res = isPost
|
|
163
|
+
? await saml.validatePostRequestAsync({
|
|
164
|
+
SAMLRequest: samlRequest,
|
|
165
|
+
...(relayState !== null ? { RelayState: relayState } : {}),
|
|
166
|
+
})
|
|
167
|
+
: await saml.validateRedirectAsync(
|
|
168
|
+
Object.fromEntries(url.searchParams) as Record<string, string>,
|
|
169
|
+
originalQuery,
|
|
170
|
+
)
|
|
171
|
+
profile = res.profile as NodeSamlProfile | null
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// Bad/absent signature, unknown issuer, malformed LogoutRequest —
|
|
174
|
+
// controlled, not a server fault. Same posture as the ACS.
|
|
175
|
+
return {
|
|
176
|
+
kind: "denied",
|
|
177
|
+
reason: `logout request rejected: ${
|
|
178
|
+
e instanceof Error ? e.message : String(e)
|
|
179
|
+
}`,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!profile) {
|
|
183
|
+
return { kind: "denied", reason: "logout request produced no profile" }
|
|
184
|
+
}
|
|
185
|
+
// Fail-closed: the replay guard below is load-bearing (it stops a
|
|
186
|
+
// captured validly-signed LogoutRequest from repeatedly driving
|
|
187
|
+
// onLogout/revokeAllForSubject). node-saml v5.1.0 already throws
|
|
188
|
+
// before returning a profile when `@ID` is absent, so this is
|
|
189
|
+
// unreachable for a conformant message — but asserting it here
|
|
190
|
+
// makes the documented fail-closed posture true in code rather
|
|
191
|
+
// than silently dependent on an upstream library invariant
|
|
192
|
+
// (mirrors the IdP-init assertion-replay handling in acs.ts).
|
|
193
|
+
if (!profile.ID) {
|
|
194
|
+
return {
|
|
195
|
+
kind: "error",
|
|
196
|
+
error: authError.internalError(
|
|
197
|
+
"saml-sp: LogoutRequest carries no @ID — front-channel replay " +
|
|
198
|
+
"protection cannot be established. Refusing to process.",
|
|
199
|
+
),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Front-channel replay dedup on the verified LogoutRequest @ID.
|
|
204
|
+
// Logout is idempotent so the blast radius is low, but a replayed
|
|
205
|
+
// signed message should not repeatedly drive revocation.
|
|
206
|
+
{
|
|
207
|
+
const key = `slo-replay:${profile.ID}`
|
|
208
|
+
const seen = await ctx.methodScratch.get(key)
|
|
209
|
+
if (seen.ok) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "denied",
|
|
212
|
+
reason: "logout request replay detected (request ID already seen)",
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const recorded = await ctx.methodScratch.put(
|
|
216
|
+
key,
|
|
217
|
+
"1",
|
|
218
|
+
SLO_REPLAY_HORIZON_MS,
|
|
219
|
+
)
|
|
220
|
+
if (isErr(recorded)) {
|
|
221
|
+
return {
|
|
222
|
+
kind: "error",
|
|
223
|
+
error: authError.internalError(
|
|
224
|
+
"saml-sp: could not record LogoutRequest ID for replay protection",
|
|
225
|
+
),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Emit the signed LogoutResponse redirect back to the IdP's SLO
|
|
231
|
+
// endpoint (success status — we always honour a validly-signed
|
|
232
|
+
// logout). node-saml uses `logoutUrl` (set above) as the target and
|
|
233
|
+
// echoes `InResponseTo` from the request profile.
|
|
234
|
+
let logoutResponseUrl: string
|
|
235
|
+
try {
|
|
236
|
+
logoutResponseUrl = await saml.getLogoutResponseUrlAsync(
|
|
237
|
+
// node-saml's Profile shape; our minimal view is structurally
|
|
238
|
+
// compatible for response generation (it reads ID / issuer).
|
|
239
|
+
profile as never,
|
|
240
|
+
relayState ?? "",
|
|
241
|
+
{},
|
|
242
|
+
true,
|
|
243
|
+
)
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return {
|
|
246
|
+
kind: "error",
|
|
247
|
+
error: authError.internalError(
|
|
248
|
+
`saml-sp: failed to build LogoutResponse: ${
|
|
249
|
+
e instanceof Error ? e.message : String(e)
|
|
250
|
+
}`,
|
|
251
|
+
e,
|
|
252
|
+
),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
kind: "challenge",
|
|
258
|
+
response: new Response(null, {
|
|
259
|
+
status: 302,
|
|
260
|
+
headers: { location: logoutResponseUrl },
|
|
261
|
+
}),
|
|
262
|
+
// Triggers the framework's onLogout side effect (host teardown +
|
|
263
|
+
// optional revokeAllForSubject) before the redirect is returned.
|
|
264
|
+
logout: {
|
|
265
|
+
...(profile.nameID !== undefined ? { nameId: profile.nameID } : {}),
|
|
266
|
+
...(profile.sessionIndex !== undefined
|
|
267
|
+
? { sessionIndex: profile.sessionIndex }
|
|
268
|
+
: {}),
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- IdP LogoutResponse (SP-initiated completion) ----------------
|
|
274
|
+
// The SP-initiated *send* path + post-logout redirect lands in the
|
|
275
|
+
// next increment; here we validate the IdP confirmed logout and
|
|
276
|
+
// acknowledge. No `logout` side effect — SP-initiated revocation
|
|
277
|
+
// happens at the trigger, not on the return leg.
|
|
278
|
+
try {
|
|
279
|
+
if (isPost) {
|
|
280
|
+
await saml.validatePostResponseAsync({
|
|
281
|
+
SAMLResponse: samlResponse as string,
|
|
282
|
+
...(relayState !== null ? { RelayState: relayState } : {}),
|
|
283
|
+
})
|
|
284
|
+
} else {
|
|
285
|
+
await saml.validateRedirectAsync(
|
|
286
|
+
Object.fromEntries(url.searchParams) as Record<string, string>,
|
|
287
|
+
originalQuery,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return {
|
|
292
|
+
kind: "denied",
|
|
293
|
+
reason: `logout response rejected: ${
|
|
294
|
+
e instanceof Error ? e.message : String(e)
|
|
295
|
+
}`,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
kind: "challenge",
|
|
301
|
+
response: new Response("Logged out.", {
|
|
302
|
+
status: 200,
|
|
303
|
+
headers: { "content-type": "text/plain" },
|
|
304
|
+
}),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SAML SP — public types.
|
|
3
|
+
*
|
|
4
|
+
* These types are the locked contract for the SAML SP method family and
|
|
5
|
+
* are intentionally introduced ahead of the runtime implementation so
|
|
6
|
+
* downstream callers can import them today, and so the public-API
|
|
7
|
+
* leak guard
|
|
8
|
+
* (`test/types/saml-sp-no-thirdparty-leaks.test.ts`) can verify
|
|
9
|
+
* the shapes before any `@node-saml/*` or `xml-crypto` code lands.
|
|
10
|
+
*
|
|
11
|
+
* No third-party types appear here. `@node-saml/node-saml`'s `Profile`
|
|
12
|
+
* is mapped onto `SamlSpProperties` at the wrapper boundary; consumers
|
|
13
|
+
* never see node-saml's surface.
|
|
14
|
+
*
|
|
15
|
+
* See `docs/plans/claude/saml-sp-plan.md` for the architectural
|
|
16
|
+
* decisions (SAML-AD1–AD7) backing this shape.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Standard SAML 2.0 NameID formats we support.
|
|
21
|
+
*
|
|
22
|
+
* - `persistent` — opaque, stable per user/SP pair; preferred.
|
|
23
|
+
* - `transient` — opaque, single-session.
|
|
24
|
+
* - `emailAddress` — RFC 822 email; common with legacy IdPs.
|
|
25
|
+
* - `unspecified` — IdP picks; pass through verbatim.
|
|
26
|
+
*/
|
|
27
|
+
export type SamlNameIdFormat =
|
|
28
|
+
| "persistent"
|
|
29
|
+
| "transient"
|
|
30
|
+
| "emailAddress"
|
|
31
|
+
| "unspecified"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One field's source. Either the assertion's NameID or a named
|
|
35
|
+
* attribute. `format` narrows attribute lookup when an IdP issues
|
|
36
|
+
* multiple attributes with the same `Name` but different `NameFormat`.
|
|
37
|
+
*/
|
|
38
|
+
export type SamlAttributeRef =
|
|
39
|
+
| { source: "nameId" }
|
|
40
|
+
| { source: "attribute"; name: string; format?: string }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How the SAML method translates a verified assertion into the
|
|
44
|
+
* `providerSubject` + per-claim fields that flow into the host's
|
|
45
|
+
* `success` callback. Mirrors the OIDC method's claim-mapping
|
|
46
|
+
* configuration — the host owns the final `SubjectClaim`; this map
|
|
47
|
+
* just normalises the SAML side.
|
|
48
|
+
*/
|
|
49
|
+
export type SamlAttributeMapping = {
|
|
50
|
+
/** Which attribute becomes `providerSubject`. Defaults to NameID. */
|
|
51
|
+
subject?: SamlAttributeRef
|
|
52
|
+
email?: SamlAttributeRef
|
|
53
|
+
/** SAML assertions are typically issued only after IdP-side verification. */
|
|
54
|
+
emailVerified?: { source: "literal"; value: boolean }
|
|
55
|
+
name?: SamlAttributeRef
|
|
56
|
+
/** Multi-valued — the mapper preserves array shape. */
|
|
57
|
+
groups?: SamlAttributeRef
|
|
58
|
+
/** Pass-through map for additional claims hosts want surfaced. */
|
|
59
|
+
custom?: Record<string, SamlAttributeRef>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A single PEM-encoded IdP signing certificate, optionally bounded by a
|
|
64
|
+
* validity window. The runtime verifier accepts any cert whose window
|
|
65
|
+
* covers `now` — supports overlapping hot rotation without re-deploys.
|
|
66
|
+
*/
|
|
67
|
+
export type SamlIdpSigningCert = {
|
|
68
|
+
pem: string
|
|
69
|
+
/** Inclusive lower bound. Unix ms. Omit for no lower bound. */
|
|
70
|
+
notBefore?: number
|
|
71
|
+
/** Exclusive upper bound. Unix ms. Omit for no upper bound. */
|
|
72
|
+
notAfter?: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Identity-Provider-side configuration the host paste/imports from the
|
|
77
|
+
* IdP's metadata XML (or uploads a metadata URL we fetch once). Stable
|
|
78
|
+
* across deploys — keyed by `(tenantId, methodId)` in `MethodStore`.
|
|
79
|
+
*/
|
|
80
|
+
export type SamlIdpConfig = {
|
|
81
|
+
entityId: string
|
|
82
|
+
/** SSO endpoint URL. HTTP-Redirect binding is the default. */
|
|
83
|
+
ssoUrl: string
|
|
84
|
+
/** Single Logout endpoint URL. Optional; Phase 3 deliverable. */
|
|
85
|
+
sloUrl?: string
|
|
86
|
+
/** Preferred NameID format requested in `AuthnRequest`. */
|
|
87
|
+
nameIdFormat?: SamlNameIdFormat
|
|
88
|
+
/** One or more IdP signing certs. ≥1 required. Hot-rotatable. */
|
|
89
|
+
signingCerts: ReadonlyArray<SamlIdpSigningCert>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* IdP-initiated SSO binding. When set, the ACS endpoint accepts
|
|
94
|
+
* unsolicited SAML Responses (no `InResponseTo`) and synthesizes a
|
|
95
|
+
* flow record using these defaults so the existing
|
|
96
|
+
* `MethodResult.success` path can run end-to-end.
|
|
97
|
+
*
|
|
98
|
+
* Documented in plan SAML-AD7 — this is the one architectural carve-out
|
|
99
|
+
* SAML imposes on the framework. Omit to reject unsolicited Responses
|
|
100
|
+
* with `invalid_request` (the conservative default).
|
|
101
|
+
*/
|
|
102
|
+
export type SamlIdpInitiatedConfig = {
|
|
103
|
+
defaultClientId: string
|
|
104
|
+
defaultRedirectUri: string
|
|
105
|
+
defaultScopes?: string[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Tenant-supplied configuration for a SAML SP method instance.
|
|
110
|
+
* Validated by `samlSpFactory.configSchema` (Standard Schema v1).
|
|
111
|
+
*/
|
|
112
|
+
export type SamlSpConfig = {
|
|
113
|
+
idp: SamlIdpConfig
|
|
114
|
+
attributeMapping: SamlAttributeMapping
|
|
115
|
+
/** Whether to sign outbound `AuthnRequest`. Default `false`. */
|
|
116
|
+
signAuthnRequest?: boolean
|
|
117
|
+
/**
|
|
118
|
+
* Per-connection SP signing keypair (SAML-AD: O3). **Decoupled from
|
|
119
|
+
* the OIDC `KeyStore` on purpose** — the SP signing cert is pinned at
|
|
120
|
+
* the IdP and rotated as an IdP-coordination event, not on the OIDC
|
|
121
|
+
* token-key schedule; this also keeps the design KMS-agnostic.
|
|
122
|
+
* `privateKeyPem` signs the `AuthnRequest`; `certPem` is what the IdP
|
|
123
|
+
* pins and what SP metadata advertises. Required iff
|
|
124
|
+
* `signAuthnRequest` is `true`. Treat `privateKeyPem` as a secret:
|
|
125
|
+
* the host should encrypt the `MethodStore` at rest (or supply it via
|
|
126
|
+
* its own resolver) — same handling as any per-tenant credential.
|
|
127
|
+
*/
|
|
128
|
+
signingKey?: { privateKeyPem: string; certPem: string }
|
|
129
|
+
/**
|
|
130
|
+
* Accept `<saml:EncryptedAssertion>` responses. **Off by default** —
|
|
131
|
+
* a connection only opts in when its IdP is configured to encrypt.
|
|
132
|
+
* When `false`, an encrypted assertion is rejected (the SP advertises
|
|
133
|
+
* no decryption cert and node-saml has no `decryptionPvk`). Requires
|
|
134
|
+
* `decryptionKey` when `true` (enforced by `configSchema`).
|
|
135
|
+
*/
|
|
136
|
+
allowEncryptedAssertions?: boolean
|
|
137
|
+
/**
|
|
138
|
+
* Per-connection SP **decryption** keypair — the private half the IdP
|
|
139
|
+
* encrypts assertions to. Same SAML-AD O3 rationale as `signingKey`:
|
|
140
|
+
* decoupled from the OIDC `KeyStore` (the IdP pins the matching cert
|
|
141
|
+
* via SP metadata; rotation is an IdP-coordination event;
|
|
142
|
+
* KMS-agnostic). Required iff `allowEncryptedAssertions` is `true`.
|
|
143
|
+
* Treat `privateKeyPem` as a secret: the host should encrypt the
|
|
144
|
+
* `MethodStore` at rest (or supply it via its own resolver) — same
|
|
145
|
+
* handling as `signingKey.privateKeyPem` and any per-tenant
|
|
146
|
+
* credential. `certPem` is the matching X.509 cert the IdP encrypts
|
|
147
|
+
* to; SP metadata advertises it as a `use="encryption"`
|
|
148
|
+
* `KeyDescriptor` (same advertise-only-what-we-serve invariant as
|
|
149
|
+
* `signingKey`).
|
|
150
|
+
*/
|
|
151
|
+
decryptionKey?: { privateKeyPem: string; certPem: string }
|
|
152
|
+
idpInitiated?: SamlIdpInitiatedConfig
|
|
153
|
+
/** Clock skew allowance for `NotBefore` / `NotOnOrAfter`. Seconds. */
|
|
154
|
+
clockSkewSeconds?: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Method-private state stashed in `FlowRecord.methodState` for the
|
|
159
|
+
* duration of an SP-initiated flow.
|
|
160
|
+
*
|
|
161
|
+
* `InResponseTo` correlation is handled out-of-band by node-saml's
|
|
162
|
+
* `CacheProvider` (backed by `methodScratch`), so the outstanding
|
|
163
|
+
* request id does **not** live here — only the framework state
|
|
164
|
+
* envelope echoed as RelayState and the issuance timestamp.
|
|
165
|
+
*/
|
|
166
|
+
export type SamlSpState = {
|
|
167
|
+
relayState: string
|
|
168
|
+
issuedAt: number
|
|
169
|
+
/**
|
|
170
|
+
* SP entityID + ACS URL computed at AuthnRequest time, where the
|
|
171
|
+
* framework's dispatch context is available. The ACS dispatch has
|
|
172
|
+
* no dispatch context, so these are read back from here to validate
|
|
173
|
+
* the assertion's `AudienceRestriction` / `Recipient` against the
|
|
174
|
+
* exact values the IdP saw in the request.
|
|
175
|
+
*/
|
|
176
|
+
spEntityId: string
|
|
177
|
+
acsUrl: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Properties handed to the host's `IdPOptions.success` callback on a
|
|
182
|
+
* successful SAML authentication. The host translates these into the
|
|
183
|
+
* final `SubjectClaim` it owns.
|
|
184
|
+
*
|
|
185
|
+
* `raw.responseXml` is provided as an escape hatch for hosts that want
|
|
186
|
+
* to inspect the verified Response themselves; the wrapper has already
|
|
187
|
+
* run the full signature gauntlet by the time these properties leave
|
|
188
|
+
* the method.
|
|
189
|
+
*/
|
|
190
|
+
export type SamlSpProperties = {
|
|
191
|
+
nameId: {
|
|
192
|
+
value: string
|
|
193
|
+
format: SamlNameIdFormat
|
|
194
|
+
}
|
|
195
|
+
attributes: Record<string, string | string[]>
|
|
196
|
+
/** Used for Single Logout correlation (Phase 3). */
|
|
197
|
+
sessionIndex?: string
|
|
198
|
+
/** Unix ms — the assertion's `AuthnInstant`. */
|
|
199
|
+
authnInstant: number
|
|
200
|
+
raw: {
|
|
201
|
+
responseXml: string
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/ports/CONSISTENCY.md
CHANGED
|
@@ -30,6 +30,7 @@ overspecifies adapters that don't need that on the read-eventual paths
|
|
|
30
30
|
| `SessionStore` | `savePar(uri, payload, ttl)` (optional) | **Strong, atomic.** `ttl` default 60 s. | RFC 9126 §3 — the PAR record must be visible to the next `consumePar` on any node. One-shot read. |
|
|
31
31
|
| `SessionStore` | `consumePar(uri)` (optional) | **Strong, atomic delete-on-read.** | RFC 9126 §4 — `request_uri` is single-use; concurrent presentations of the same uri resolve to one winner. Same semantics as `consumeFlow`. |
|
|
32
32
|
| `SessionStore` | `createSession / readSession / revokeSession` (optional long-lived) | **Strong.** | Session creation must be immediately readable on the next request. |
|
|
33
|
+
| `SessionStore` | `saveScratch / readScratch / deleteScratch` (optional) | **Strong, TTL-respecting.** Implement the trio together; partial implementations are not supported. | Backs `MethodContext.methodScratch` for methods that need cross-flow per-instance state (e.g. SAML SP assertion-ID replay protection). Keys arrive already namespaced by `(tenantId, methodId)` — the adapter stores opaque key/value pairs. |
|
|
33
34
|
| `TokenStore` | `recordDpopJti(jti, ttlMs)` (optional) | **Strong, atomic record-or-fail.** | RFC 9449 §11.1 — replay protection requires single-use enforcement on the jti within the TTL window. A re-presentation must return `invalid_grant` deterministically. Adapters without this method cannot satisfy DPoP and the verifier surfaces `invalid_dpop_proof`. |
|
|
34
35
|
| `KeyStore` | `currentSigningKey()` / `currentEncryptionKey()` | Strong. | Active key must be unambiguous. |
|
|
35
36
|
| `KeyStore` | `signingKeys()` (JWKS) | Eventual OK (with TTL). | Verifiers tolerate brief JWKS lag during rotation. |
|
|
@@ -64,6 +65,12 @@ demonstrate, under simulated replication lag:
|
|
|
64
65
|
2. `consumeRefresh` CAS resolves to exactly one winner under concurrent
|
|
65
66
|
attempts on the same token.
|
|
66
67
|
3. `revokeBySubject` propagates within the documented SLA.
|
|
68
|
+
4. `readScratch` immediately after the matching `saveScratch` returns the
|
|
69
|
+
value. Scratch backs SAML SP InResponseTo replay protection: the
|
|
70
|
+
AuthnRequest is correlated at the ACS by a scratch read that must see
|
|
71
|
+
the write made when the request was issued. The D1 adapter pins all
|
|
72
|
+
three scratch operations to the primary via the Sessions API
|
|
73
|
+
(`primarySession`), the same path `consumeFlow` uses.
|
|
67
74
|
|
|
68
75
|
Read-eventual paths (`ConfigStore.getTenantConfig`, JWKS) may use replicas
|
|
69
76
|
freely.
|
|
@@ -93,6 +100,11 @@ The fixture set covers, at minimum:
|
|
|
93
100
|
one-shot consume atomicity, expiry enforcement, ttl-0 rejection.
|
|
94
101
|
- **DPoP jti (`recordDpopJti`)** — first record succeeds, replay within
|
|
95
102
|
TTL fails with `invalid_grant`, post-TTL the slot is freed for reuse.
|
|
103
|
+
- **Method scratch (`supportsScratch: true`)** — `saveScratch` /
|
|
104
|
+
`readScratch` / `deleteScratch` round-trip, overwrite semantics on
|
|
105
|
+
same key, expiry enforcement, idempotent `deleteScratch`, ttl-0
|
|
106
|
+
rejection. Keys are opaque to the adapter (the framework scopes them
|
|
107
|
+
upstream); a single test confirms that distinct keys are isolated.
|
|
96
108
|
|
|
97
109
|
## Optional methods and graceful degradation
|
|
98
110
|
|
|
@@ -110,6 +122,19 @@ caught up yet:
|
|
|
110
122
|
does not support DPoP replay protection"`. Memory adapter
|
|
111
123
|
implements; production adapters add it the same way `consumeRefresh`
|
|
112
124
|
uses an atomic write-once primitive.
|
|
125
|
+
- `SessionStore.saveScratch / readScratch / deleteScratch` — required
|
|
126
|
+
for methods that hold cross-flow per-instance state (SAML SP replay
|
|
127
|
+
protection is the first user). Without them,
|
|
128
|
+
`MethodContext.methodScratch.put/get/delete` returns an
|
|
129
|
+
`internal_error` whose description names the missing operation.
|
|
130
|
+
Memory **and all four production adapters** (Postgres, D1, DynamoDB,
|
|
131
|
+
Durable Object) implement the trio as a TTL-respecting key/value
|
|
132
|
+
store keyed by an opaque string; each is opted into the
|
|
133
|
+
`supportsScratch` conformance cases. Semantics are upsert /
|
|
134
|
+
TTL-filtered read / idempotent delete — there is no atomic
|
|
135
|
+
delete-on-read (unlike `consumeFlow`). On DynamoDB the row also
|
|
136
|
+
carries a native `ttl` attribute for backstop eviction, but reads
|
|
137
|
+
still filter on the adapter clock because native TTL is best-effort.
|
|
113
138
|
|
|
114
139
|
The framework never advertises a feature in discovery that the wired
|
|
115
140
|
adapters cannot actually serve: `pushed_authorization_request_endpoint`
|
package/src/ports/audit-log.ts
CHANGED
|
@@ -93,16 +93,29 @@ export type AuditEvent =
|
|
|
93
93
|
}
|
|
94
94
|
| {
|
|
95
95
|
/**
|
|
96
|
-
* Emitted
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
96
|
+
* Emitted after a logout is processed. Two channels, discriminated
|
|
97
|
+
* by `via`:
|
|
98
|
+
*
|
|
99
|
+
* - `rp_initiated` (or absent — the default): `/end_session`
|
|
100
|
+
* (OIDC RP-Initiated Logout 1.0 §2), regardless of whether a
|
|
101
|
+
* `post_logout_redirect_uri` was supplied. `subjectId` present
|
|
102
|
+
* when an `id_token_hint` verified; absent otherwise.
|
|
103
|
+
* - `upstream_slo`: an upstream IdP notified us a federated
|
|
104
|
+
* session ended (SAML front-channel Single Logout). `methodId`
|
|
105
|
+
* / `methodKind` identify the federation connection;
|
|
106
|
+
* `subjectId` present only when the host's `onLogout` returned
|
|
107
|
+
* a subject to revoke.
|
|
108
|
+
*
|
|
109
|
+
* `via` is general (OIDC back-channel logout would reuse
|
|
110
|
+
* `upstream_slo`), not SAML-specific surface.
|
|
101
111
|
*/
|
|
102
112
|
kind: "session_logout"
|
|
103
113
|
tenantId: TenantId
|
|
104
114
|
clientId?: string
|
|
105
115
|
subjectId?: string
|
|
116
|
+
via?: "rp_initiated" | "upstream_slo"
|
|
117
|
+
methodId?: string
|
|
118
|
+
methodKind?: string
|
|
106
119
|
}
|
|
107
120
|
| {
|
|
108
121
|
/**
|
|
@@ -101,6 +101,34 @@ export type SessionStore = {
|
|
|
101
101
|
ttl: number,
|
|
102
102
|
): Promise<Result<void>>
|
|
103
103
|
consumePar?(requestUri: string): Promise<Result<ParRecord>>
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Optional: per-method-instance scratch storage. Survives across flows
|
|
107
|
+
* (unlike `methodState`, which is per-flow). Use cases include cross-flow
|
|
108
|
+
* deduplication state — e.g., a SAML SP method remembering recently-seen
|
|
109
|
+
* assertion IDs for replay protection.
|
|
110
|
+
*
|
|
111
|
+
* The framework scopes keys per `(tenantId, methodId)` before calling
|
|
112
|
+
* these — adapters see opaque, already-namespaced keys and store the
|
|
113
|
+
* UTF-8 string value verbatim. Methods JSON-encode if they want object
|
|
114
|
+
* state.
|
|
115
|
+
*
|
|
116
|
+
* Strong consistency + TTL respect (same semantics as flow records).
|
|
117
|
+
* `readScratch` returns `unknown_state` if the key is missing or
|
|
118
|
+
* expired. `deleteScratch` is idempotent.
|
|
119
|
+
*
|
|
120
|
+
* Adapters without these methods cannot host methods that depend on
|
|
121
|
+
* scratch; the framework surfaces a clear `unsupported` error through
|
|
122
|
+
* `MethodContext.methodScratch` at call time. Implement all three
|
|
123
|
+
* methods together — partial implementations are not supported.
|
|
124
|
+
*/
|
|
125
|
+
saveScratch?(
|
|
126
|
+
key: string,
|
|
127
|
+
value: string,
|
|
128
|
+
ttlMs: number,
|
|
129
|
+
): Promise<Result<void>>
|
|
130
|
+
readScratch?(key: string): Promise<Result<string>>
|
|
131
|
+
deleteScratch?(key: string): Promise<Result<void>>
|
|
104
132
|
}
|
|
105
133
|
|
|
106
134
|
/**
|