@voltro/plugin-sso-saml 0.1.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/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +1285 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.js +443 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { ColumnDefinition } from '@voltro/database';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { HttpClient } from '@effect/platform';
|
|
4
|
+
import { Schema } from 'effect';
|
|
5
|
+
import { Subject } from '@voltro/protocol';
|
|
6
|
+
import { TableIndex } from '@voltro/database';
|
|
7
|
+
import { TableLike } from '@voltro/database';
|
|
8
|
+
import { VoltroPlugin } from '@voltro/protocol';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A DataStore-backed {@link SamlLogoutStore}. `save` deletes any prior row for
|
|
12
|
+
* the key then inserts (a fresh login replaces the record), `load` reads +
|
|
13
|
+
* prunes on expiry, `remove` deletes (one-shot). Shared across replicas because
|
|
14
|
+
* the store is shared. `ttlMs` bounds how long a login's logout state stays
|
|
15
|
+
* valid (a stale row reads as absent and is pruned).
|
|
16
|
+
*/
|
|
17
|
+
export declare const dataStoreLogoutStore: (store: LogoutSessionStore, ttlMs?: number) => SamlLogoutStore;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A DataStore-backed {@link SamlCacheProvider}. `saveAsync` INSERTs the request
|
|
21
|
+
* id (the `unique` column makes a concurrent duplicate insert throw → treated
|
|
22
|
+
* as "already present", i.e. a replay), `getAsync` reads it, `removeAsync`
|
|
23
|
+
* deletes it (one-time consumption). Shared across replicas because the store
|
|
24
|
+
* is shared.
|
|
25
|
+
*
|
|
26
|
+
* `ttlMs` bounds how long an outstanding request stays valid — a stale row past
|
|
27
|
+
* the window reads as absent (and is pruned on read), so an unconsumed request
|
|
28
|
+
* can't be replayed indefinitely.
|
|
29
|
+
*/
|
|
30
|
+
export declare const dataStoreReplayCache: (store: ReplayCacheStore, ttlMs?: number) => SamlCacheProvider;
|
|
31
|
+
|
|
32
|
+
/** node-saml's `profile` object → a normalized identity. node-saml flattens
|
|
33
|
+
* single-valued attributes onto the profile, and exposes the rest under
|
|
34
|
+
* `attributes`. We pick the common claim URIs IdPs send. */
|
|
35
|
+
export declare const extractProfile: (profile: Record<string, unknown>) => SamlProfileResult;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetch + parse the IdP metadata at `url` via @effect/platform `HttpClient`.
|
|
39
|
+
* The returned Effect requires an `HttpClient` in context (provide
|
|
40
|
+
* `FetchHttpClient.layer` at the edge). Fetch failures → {@link SamlMetadataFetchError};
|
|
41
|
+
* a fetched-but-unparseable document → {@link SamlMetadataParseError}.
|
|
42
|
+
*/
|
|
43
|
+
export declare const fetchIdpMetadata: (url: string) => Effect.Effect<IdpMetadata, SamlMetadataFetchError | SamlMetadataParseError, HttpClient.HttpClient>;
|
|
44
|
+
|
|
45
|
+
/** The two IdP values a metadata document supplies. */
|
|
46
|
+
export declare interface IdpMetadata {
|
|
47
|
+
/** The `SingleSignOnService` `Location` (HTTP-Redirect binding preferred) — node-saml's `entryPoint`. */
|
|
48
|
+
readonly entryPoint: string;
|
|
49
|
+
/** The IdP signing certificate PEM body (from the signing `KeyDescriptor`). */
|
|
50
|
+
readonly idpCert: string;
|
|
51
|
+
/** The IdP `SingleLogoutService` `Location`, when the metadata declares one — node-saml's `logoutUrl`. */
|
|
52
|
+
readonly logoutUrl?: string;
|
|
53
|
+
/** The IdP `entityID` — node-saml's `idpIssuer`. */
|
|
54
|
+
readonly issuer?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export declare const LOGOUT_SESSION_TABLE = "_voltro_saml_logout";
|
|
58
|
+
|
|
59
|
+
/** Narrow slice of the framework DataStore the SLO store needs. */
|
|
60
|
+
export declare interface LogoutSessionStore {
|
|
61
|
+
query: (descriptor: Record<string, unknown>) => Promise<ReadonlyArray<Record<string, unknown>>>;
|
|
62
|
+
insert: (table: string, row: Record<string, unknown>) => Promise<unknown>;
|
|
63
|
+
delete: (table: string, primaryKey: string) => Promise<unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* In-memory {@link SamlLogoutStore} — the single-replica / dev default. Correct
|
|
68
|
+
* for one process; under >1 replica use {@link dataStoreLogoutStore} so a login
|
|
69
|
+
* on one replica can log out on another.
|
|
70
|
+
*/
|
|
71
|
+
export declare const memoryLogoutStore: (ttlMs?: number) => SamlLogoutStore;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* In-memory {@link SamlCacheProvider} — the single-replica / dev default.
|
|
75
|
+
* Correct for one process; under >1 replica use {@link dataStoreReplayCache}.
|
|
76
|
+
*/
|
|
77
|
+
export declare const memoryReplayCache: (ttlMs?: number) => SamlCacheProvider;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse an IdP SAML-metadata XML string → the SSO endpoint + signing cert.
|
|
81
|
+
* Pure (no I/O) so it is unit-testable against a fixture document.
|
|
82
|
+
*/
|
|
83
|
+
export declare const parseIdpMetadata: (xml: string, url: string) => Effect.Effect<IdpMetadata, SamlMetadataParseError>;
|
|
84
|
+
|
|
85
|
+
export declare const REPLAY_CACHE_TABLE = "_voltro_saml_replay";
|
|
86
|
+
|
|
87
|
+
/** Narrow slice of the framework DataStore this cache needs. */
|
|
88
|
+
export declare interface ReplayCacheStore {
|
|
89
|
+
query: (descriptor: Record<string, unknown>) => Promise<ReadonlyArray<Record<string, unknown>>>;
|
|
90
|
+
insert: (table: string, row: Record<string, unknown>) => Promise<unknown>;
|
|
91
|
+
delete: (table: string, primaryKey: string) => Promise<unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** node-saml's `{ value, createdAt }` cache item (see @node-saml/node-saml types). */
|
|
95
|
+
export declare interface SamlCacheItem {
|
|
96
|
+
readonly value: string;
|
|
97
|
+
readonly createdAt: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The node-saml `CacheProvider` contract (structurally — we don't import its type). */
|
|
101
|
+
export declare interface SamlCacheProvider {
|
|
102
|
+
saveAsync(key: string, value: string): Promise<SamlCacheItem | null>;
|
|
103
|
+
getAsync(key: string): Promise<string | null>;
|
|
104
|
+
removeAsync(key: string | null): Promise<string | null>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The persisted SLO record: a login's logout state, one-shot per `sloKey`. */
|
|
108
|
+
export declare interface SamlLogoutRecord extends SamlLogoutSubject {
|
|
109
|
+
readonly sloKey: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Store contract the SLO record needs — save at ACS, load + consume at logout. */
|
|
113
|
+
export declare interface SamlLogoutStore {
|
|
114
|
+
/** Persist the logout state under `sloKey` (overwrites any prior row). */
|
|
115
|
+
save(record: SamlLogoutRecord): Promise<void>;
|
|
116
|
+
/** Load the logout state for `sloKey`, or `null` when unknown / expired. */
|
|
117
|
+
load(sloKey: string): Promise<SamlLogoutSubject | null>;
|
|
118
|
+
/** Consume (delete) the record — logout is one-shot. */
|
|
119
|
+
remove(sloKey: string): Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The NameID + SessionIndex a `LogoutRequest` references (from the login assertion). */
|
|
123
|
+
export declare interface SamlLogoutSubject {
|
|
124
|
+
/** The IdP `NameID` value (the login subject). */
|
|
125
|
+
readonly nameId: string;
|
|
126
|
+
/** The `NameID` `Format` URI, echoed on the LogoutRequest (optional). */
|
|
127
|
+
readonly nameIdFormat?: string;
|
|
128
|
+
/** The `SessionIndex` from the assertion (the IdP session to terminate). */
|
|
129
|
+
readonly sessionIndex?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The slice of a built `Table` this package exposes. Annotated explicitly — the
|
|
134
|
+
* full inferred `Table` type leaks @voltro/database's private column-builder
|
|
135
|
+
* class across the package boundary (TS4094); the schema contribution only
|
|
136
|
+
* needs the name, columns, and indexes.
|
|
137
|
+
*/
|
|
138
|
+
export declare interface SamlLogoutTable extends TableLike {
|
|
139
|
+
readonly fields: Record<string, ColumnDefinition<unknown>>;
|
|
140
|
+
readonly appliedIndexes: ReadonlyArray<TableIndex>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The SLO-session table: one row per active login, holding the NameID +
|
|
145
|
+
* SessionIndex a LogoutRequest must reference. `sloKey` is the opaque cookie
|
|
146
|
+
* key (unique — a new login replaces the row); `createdAtMs` drives the TTL
|
|
147
|
+
* sweep. Built from column helpers so the DDL compiler handles per-dialect
|
|
148
|
+
* divergence.
|
|
149
|
+
*/
|
|
150
|
+
export declare const samlLogoutTable: SamlLogoutTable;
|
|
151
|
+
|
|
152
|
+
/** The SLO-session table, ready to spread into `extendSchema.tables`. */
|
|
153
|
+
export declare const samlLogoutTables: ReadonlyArray<SamlLogoutTable>;
|
|
154
|
+
|
|
155
|
+
/** Fetching the IdP metadata document failed (network / non-2xx / empty body). */
|
|
156
|
+
export declare class SamlMetadataFetchError extends SamlMetadataFetchError_base {
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
declare const SamlMetadataFetchError_base: Schema.TaggedErrorClass<SamlMetadataFetchError, "SamlMetadataFetchError", {
|
|
160
|
+
readonly _tag: Schema.tag<"SamlMetadataFetchError">;
|
|
161
|
+
} & {
|
|
162
|
+
/** The metadata URL that was requested (safe to log — it's config, not a secret). */
|
|
163
|
+
url: typeof Schema.String;
|
|
164
|
+
/** Coarse reason (`status 404`, `network`, `empty-body`). Never a secret. */
|
|
165
|
+
reason: typeof Schema.String;
|
|
166
|
+
}>;
|
|
167
|
+
|
|
168
|
+
/** The IdP metadata XML was fetched but the SSO endpoint / signing cert could not be extracted. */
|
|
169
|
+
export declare class SamlMetadataParseError extends SamlMetadataParseError_base {
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
declare const SamlMetadataParseError_base: Schema.TaggedErrorClass<SamlMetadataParseError, "SamlMetadataParseError", {
|
|
173
|
+
readonly _tag: Schema.tag<"SamlMetadataParseError">;
|
|
174
|
+
} & {
|
|
175
|
+
url: typeof Schema.String;
|
|
176
|
+
/** What was missing (`no-entryPoint`, `no-signing-cert`). */
|
|
177
|
+
reason: typeof Schema.String;
|
|
178
|
+
}>;
|
|
179
|
+
|
|
180
|
+
/** The normalized identity extracted from a validated assertion. */
|
|
181
|
+
export declare interface SamlProfileResult {
|
|
182
|
+
readonly nameId: string;
|
|
183
|
+
readonly email: string | null;
|
|
184
|
+
readonly displayName: string | null;
|
|
185
|
+
readonly attributes: Record<string, unknown>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The slice of a built `Table` this package exposes. Annotated explicitly —
|
|
190
|
+
* the full inferred `Table` type leaks @voltro/database's private
|
|
191
|
+
* column-builder class across the package boundary (TS4094); the schema
|
|
192
|
+
* contribution only needs the name, columns, and indexes.
|
|
193
|
+
*/
|
|
194
|
+
export declare interface SamlReplayTable extends TableLike {
|
|
195
|
+
readonly fields: Record<string, ColumnDefinition<unknown>>;
|
|
196
|
+
readonly appliedIndexes: ReadonlyArray<TableIndex>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The replay cache table: one row per outstanding AuthnRequest id. `requestId`
|
|
201
|
+
* is the node-saml cache KEY (the SAML request/response id); `instant` is the
|
|
202
|
+
* ISO timestamp node-saml stores as the value; `createdAtMs` is the epoch-ms
|
|
203
|
+
* the pruning window measures against. Built purely from column helpers so the
|
|
204
|
+
* DDL compiler handles per-dialect divergence.
|
|
205
|
+
*/
|
|
206
|
+
export declare const samlReplayTable: SamlReplayTable;
|
|
207
|
+
|
|
208
|
+
/** The replay-cache table, ready to spread into `extendSchema.tables`. */
|
|
209
|
+
export declare const samlReplayTables: ReadonlyArray<SamlReplayTable>;
|
|
210
|
+
|
|
211
|
+
export declare interface SamlSpConfig {
|
|
212
|
+
/** SP entity id (audience). e.g. `https://app.example.com/saml/metadata`. */
|
|
213
|
+
readonly entityId: string;
|
|
214
|
+
/** Assertion Consumer Service URL the IdP POSTs the response to. */
|
|
215
|
+
readonly acsUrl: string;
|
|
216
|
+
/** SP Single Logout Service URL — where the IdP sends LogoutRequest /
|
|
217
|
+
* LogoutResponse (HTTP-Redirect binding). Advertised in the metadata when set. */
|
|
218
|
+
readonly sloUrl?: string;
|
|
219
|
+
/** Whether the SP signs its AuthnRequest / LogoutRequest — flips the metadata
|
|
220
|
+
* `AuthnRequestsSigned` flag. Set when `privateKey` is configured. */
|
|
221
|
+
readonly authnRequestsSigned?: boolean;
|
|
222
|
+
/** The SP signing certificate (PEM body) — published in a signing
|
|
223
|
+
* `KeyDescriptor` so the IdP can verify the SP's request signatures. */
|
|
224
|
+
readonly signingCert?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export declare const samlSsoPlugin: (options: SamlSsoPluginOptions) => VoltroPlugin;
|
|
228
|
+
|
|
229
|
+
export declare interface SamlSsoPluginOptions {
|
|
230
|
+
readonly idp: {
|
|
231
|
+
/** IdP SSO redirect endpoint (where the AuthnRequest goes). Omit ONLY when
|
|
232
|
+
* `metadataUrl` is set — the endpoint is then read from the IdP metadata. */
|
|
233
|
+
readonly entryPoint?: string;
|
|
234
|
+
/** IdP signing certificate (PEM body) — used to verify the assertion. Omit
|
|
235
|
+
* ONLY when `metadataUrl` is set (the cert is read + auto-rotated from it). */
|
|
236
|
+
readonly idpCert?: string;
|
|
237
|
+
/** Expected issuer (IdP entity id). Optional. */
|
|
238
|
+
readonly issuer?: string;
|
|
239
|
+
/** IdP Single Logout endpoint (where a LogoutRequest / LogoutResponse goes).
|
|
240
|
+
* Optional — falls back to the metadata `SingleLogoutService` when fetched. */
|
|
241
|
+
readonly logoutUrl?: string;
|
|
242
|
+
/**
|
|
243
|
+
* IdP SAML metadata document URL. When set, the plugin FETCHES it at boot
|
|
244
|
+
* (via @effect/platform HttpClient) and reads `entryPoint` + the signing
|
|
245
|
+
* `idpCert` (+ `logoutUrl`) from it — so cert rotation on the IdP no longer
|
|
246
|
+
* needs a manual `idpCert` edit. Re-fetched every `metadataRefreshMs`.
|
|
247
|
+
* `entryPoint` / `idpCert` supplied explicitly still win as a fallback if
|
|
248
|
+
* the fetch fails.
|
|
249
|
+
*/
|
|
250
|
+
readonly metadataUrl?: string;
|
|
251
|
+
/** How often to re-fetch `metadataUrl` (ms). Default 12h. `0` disables refresh. */
|
|
252
|
+
readonly metadataRefreshMs?: number;
|
|
253
|
+
};
|
|
254
|
+
readonly sp: SamlSpConfig;
|
|
255
|
+
/** HMAC secret for the minted session cookie. */
|
|
256
|
+
readonly sessionSecret: string;
|
|
257
|
+
/** Map the validated SAML identity → an app Subject (look up / JIT-create a
|
|
258
|
+
* user). Return `null` to reject the login. */
|
|
259
|
+
readonly onLogin: (profile: SamlProfileResult) => Promise<Subject | null> | Subject | null;
|
|
260
|
+
/** Where to send the browser after a successful login. Default `/`. */
|
|
261
|
+
readonly successRedirect?: string;
|
|
262
|
+
/** Where to send the browser after logout completes. Default `/`. */
|
|
263
|
+
readonly logoutRedirect?: string;
|
|
264
|
+
/** Session cookie name. Default `voltro:session`. */
|
|
265
|
+
readonly cookieName?: string;
|
|
266
|
+
/** Mount prefix. Default `/saml`. */
|
|
267
|
+
readonly basePath?: string;
|
|
268
|
+
/** Lifetime of the minted session cookie, in seconds. Default 7 days. */
|
|
269
|
+
readonly sessionTtlSeconds?: number;
|
|
270
|
+
/**
|
|
271
|
+
* Encrypted-assertion support. The SP's PRIVATE key (PEM) node-saml uses to
|
|
272
|
+
* DECRYPT an `EncryptedAssertion` the IdP sends. Sourced from env / a secrets
|
|
273
|
+
* backend — NEVER a literal, NEVER logged. When set, encrypted assertions
|
|
274
|
+
* decrypt transparently; unencrypted ones still work.
|
|
275
|
+
*/
|
|
276
|
+
readonly decryptionPvk?: string;
|
|
277
|
+
/**
|
|
278
|
+
* SP request signing. The SP PRIVATE key (PEM) node-saml uses to SIGN the
|
|
279
|
+
* AuthnRequest + LogoutRequest, so the IdP can verify they came from this SP.
|
|
280
|
+
* Sourced from env / a secrets backend — NEVER a literal, NEVER logged. When
|
|
281
|
+
* set, the SP metadata advertises `AuthnRequestsSigned="true"`.
|
|
282
|
+
*/
|
|
283
|
+
readonly privateKey?: string;
|
|
284
|
+
/** The SP signing certificate (PEM) that pairs with `privateKey` — published in
|
|
285
|
+
* the SP metadata so the IdP can verify the SP's request signatures. */
|
|
286
|
+
readonly signingCert?: string;
|
|
287
|
+
/**
|
|
288
|
+
* Clock-skew tolerance for assertion timestamp validation, in ms. IdP and SP
|
|
289
|
+
* clocks drift; a small window (e.g. 5000) avoids spurious `NotBefore` /
|
|
290
|
+
* `NotOnOrAfter` rejections. Wired to node-saml's `acceptedClockSkewMs`.
|
|
291
|
+
*/
|
|
292
|
+
readonly acceptedClockSkewMs?: number;
|
|
293
|
+
/**
|
|
294
|
+
* Assertion-replay / `InResponseTo` protection. When enabled, node-saml is
|
|
295
|
+
* configured with `validateInResponseTo: 'always'` and a cache: the
|
|
296
|
+
* AuthnRequest id is stored at login and CONSUMED (one-time) at the ACS, so
|
|
297
|
+
* a captured `SAMLResponse` can't be replayed and an unsolicited IdP-initiated
|
|
298
|
+
* POST (no matching request) is rejected.
|
|
299
|
+
*
|
|
300
|
+
* - `false` (default) — OFF (node-saml default; single captured response
|
|
301
|
+
* replayable within its validity window). Keep the default only for a
|
|
302
|
+
* single-replica dev/PoC.
|
|
303
|
+
* - `true` — ON with an in-PROCESS cache. Correct for ONE replica; under
|
|
304
|
+
* >1 replica a login and its ACS can land on different processes and the
|
|
305
|
+
* check breaks. Boot warns when this is used.
|
|
306
|
+
* - `{ store: true }` — ON backed by the framework DataStore (a contributed
|
|
307
|
+
* `_voltro_saml_replay` table). Shared across replicas — the correct
|
|
308
|
+
* production choice. Requires `store:write` (table DDL); the plugin
|
|
309
|
+
* declares it. `ttlMs` bounds how long an outstanding request stays valid
|
|
310
|
+
* (default 10 min).
|
|
311
|
+
*/
|
|
312
|
+
readonly replayProtection?: boolean | {
|
|
313
|
+
readonly store: true;
|
|
314
|
+
readonly ttlMs?: number;
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Single Logout (SLO) state store. SP-initiated logout must reference the
|
|
318
|
+
* NameID + SessionIndex from the login, so they're persisted at ACS keyed by
|
|
319
|
+
* an opaque companion cookie.
|
|
320
|
+
*
|
|
321
|
+
* - `false` / omitted (default) — in-PROCESS store (single replica only).
|
|
322
|
+
* - `{ store: true }` — backed by the framework DataStore (a contributed
|
|
323
|
+
* `_voltro_saml_logout` table), so a login on one replica can log out on
|
|
324
|
+
* another. Requires `store:write`; the plugin declares it.
|
|
325
|
+
*/
|
|
326
|
+
readonly sloStore?: boolean | {
|
|
327
|
+
readonly store: true;
|
|
328
|
+
};
|
|
329
|
+
readonly name?: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** SP metadata XML to hand the IdP (it tells the IdP the ACS URL + entity id). */
|
|
333
|
+
export declare const spMetadataXml: (sp: SamlSpConfig) => string;
|
|
334
|
+
|
|
335
|
+
export { }
|