@getstrata/core 1.0.9 → 1.1.1

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/core/auth/authContext.d.ts +5 -2
  3. package/dist/core/auth/emailVerification.d.ts +1 -1
  4. package/dist/core/auth/guard.d.ts +5 -1
  5. package/dist/core/auth/jwtGuard.d.ts +6 -2
  6. package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
  7. package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
  8. package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
  9. package/dist/core/auth/oneTimeToken.d.ts +19 -0
  10. package/dist/core/auth/passwordLogin.d.ts +20 -0
  11. package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
  12. package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
  13. package/dist/core/contracts/authUserDirectory.d.ts +2 -3
  14. package/dist/core/database/errors.d.ts +2 -1
  15. package/dist/core/http/response.d.ts +3 -1
  16. package/dist/core/mail/mailer.d.ts +6 -1
  17. package/dist/core/security/oauthState.d.ts +6 -2
  18. package/dist/core/security/safeFetch.d.ts +1 -0
  19. package/dist/core/security/safePath.d.ts +4 -0
  20. package/dist/core/security/safeUrl.d.ts +14 -1
  21. package/dist/core/security/totp.d.ts +1 -1
  22. package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
  23. package/dist/core/tenant/enableTenantRls.d.ts +6 -0
  24. package/dist/core/tenant/postgresAppRole.d.ts +75 -0
  25. package/dist/entries/audit/exportAuditLogs.js +223 -40
  26. package/dist/entries/auth/accessControl.js +1 -0
  27. package/dist/entries/auth/basicAuthGuard.js +638 -17
  28. package/dist/entries/auth/emailVerification.js +1 -1
  29. package/dist/entries/auth/intendedUrlCookie.js +32 -2
  30. package/dist/entries/auth/jwt.js +1 -1
  31. package/dist/entries/auth/jwtGuard.js +192 -3
  32. package/dist/entries/auth/membershipMiddleware.js +1 -0
  33. package/dist/entries/auth/membershipScope.js +1 -0
  34. package/dist/entries/auth/membershipService.js +1 -0
  35. package/dist/entries/auth/oauth/oidcProvider.js +542 -20
  36. package/dist/entries/auth/oauth/providers.js +313 -5
  37. package/dist/entries/auth/oauth/samlProvider.js +552 -74
  38. package/dist/entries/auth/oneTimeToken.js +362 -0
  39. package/dist/entries/auth/password.js +1 -1
  40. package/dist/entries/auth/passwordLogin.js +548 -0
  41. package/dist/entries/auth/policy.js +1 -0
  42. package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
  43. package/dist/entries/auth/scimAuthMiddleware.js +10 -3
  44. package/dist/entries/auth/sessionCookie.js +1 -1
  45. package/dist/entries/auth/sessionGuard.js +1 -1
  46. package/dist/entries/auth/tokenHash.js +2 -6
  47. package/dist/entries/crypto/fieldEncryption.js +1 -1
  48. package/dist/entries/crypto/mfaSecret.js +31 -5
  49. package/dist/entries/database/errors.js +29 -12
  50. package/dist/entries/database/model.js +9 -9
  51. package/dist/entries/facades.js +72 -9
  52. package/dist/entries/http/authMiddleware.js +7 -14
  53. package/dist/entries/http/corsMiddleware.js +69 -2
  54. package/dist/entries/http/csrfMiddleware.js +33 -43
  55. package/dist/entries/http/csrfToken.js +2 -2
  56. package/dist/entries/http/flashMiddleware.js +4 -2
  57. package/dist/entries/http/flashSession.js +4 -2
  58. package/dist/entries/http/formRequest.js +2 -2
  59. package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
  60. package/dist/entries/http/pagination.js +2 -2
  61. package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
  62. package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
  63. package/dist/entries/http/response.js +101 -45
  64. package/dist/entries/http/routeModelBinding.js +2 -2
  65. package/dist/entries/http/safeInternalPath.js +32 -2
  66. package/dist/entries/http/scimThrottleMiddleware.js +7 -1
  67. package/dist/entries/http/securedRouteModelBinding.js +2 -2
  68. package/dist/entries/http/securityHeadersMiddleware.js +3 -64
  69. package/dist/entries/http/signedUrl.js +6 -5
  70. package/dist/entries/http/throttleMiddleware.js +63 -0
  71. package/dist/entries/http/validation.js +2 -2
  72. package/dist/entries/http/webFormRequest.js +2 -2
  73. package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
  74. package/dist/entries/mail/mailer.js +54 -8
  75. package/dist/entries/media/imageTransform.js +12 -1
  76. package/dist/entries/openapi/generator.js +0 -7
  77. package/dist/entries/security/oauthState.js +30 -39
  78. package/dist/entries/security/publicReads.js +1 -1
  79. package/dist/entries/security/recoveryCodes.js +2 -2
  80. package/dist/entries/security/safeFetch.js +195 -94
  81. package/dist/entries/security/safePath.js +25 -0
  82. package/dist/entries/security/scimTenantTokens.js +10 -3
  83. package/dist/entries/security/totp.js +16 -4
  84. package/dist/entries/storage/storage.js +24 -2
  85. package/dist/entries/tenant/databaseTenantContext.js +34 -12
  86. package/dist/entries/tenant/enableTenantRls.js +385 -0
  87. package/dist/entries/tracing/tracingMiddleware.js +308 -5
  88. package/dist/framework/public-api.d.ts +10 -3
  89. package/dist/index.js +2767 -1515
  90. package/package.json +32 -3
@@ -1,107 +1,585 @@
1
1
  // @bun
2
- // ../../src/core/runtime/appEnv.ts
3
- var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
4
- function normalizeEnvValue(value) {
5
- return (value ?? "").trim().toLowerCase();
6
- }
7
- function isProductionEnv(env = process.env) {
8
- const appEnv = normalizeEnvValue(env.APP_ENV);
9
- const nodeEnv = normalizeEnvValue(env.NODE_ENV);
10
- if (appEnv === "production" || nodeEnv === "production") {
11
- return true;
12
- }
13
- if (appEnv === "") {
2
+ // ../../src/core/runtime/optionalPeer.ts
3
+ function missingOptionalPeer(packageName, reason, error) {
4
+ return new Error(`Install ${packageName} ${reason} (\`bun add ${packageName}\`).`, {
5
+ cause: error
6
+ });
7
+ }
8
+
9
+ // ../../src/core/security/timingSafeCompare.ts
10
+ import { timingSafeEqual } from "crypto";
11
+ function timingSafeCompareString(left, right) {
12
+ const leftBuffer = Buffer.from(left);
13
+ const rightBuffer = Buffer.from(right);
14
+ if (leftBuffer.length !== rightBuffer.length) {
14
15
  return false;
15
16
  }
16
- return !NON_PRODUCTION_APP_ENVS.has(appEnv);
17
+ return timingSafeEqual(leftBuffer, rightBuffer);
18
+ }
19
+
20
+ // ../../src/core/database/boundConnection.ts
21
+ var BOUND_CONNECTION_KEY = Symbol.for("@getstrata/boundDatabaseConnection");
22
+ function boundConnectionState() {
23
+ const globalRecord = globalThis;
24
+ const existing = globalRecord[BOUND_CONNECTION_KEY];
25
+ if (existing) {
26
+ return existing;
27
+ }
28
+ const created = { connection: null };
29
+ globalRecord[BOUND_CONNECTION_KEY] = created;
30
+ return created;
31
+ }
32
+ function getBoundDatabaseConnection() {
33
+ return boundConnectionState().connection;
34
+ }
35
+
36
+ // ../../src/core/runtime/asyncContextStore.ts
37
+ import { AsyncLocalStorage } from "async_hooks";
38
+ function createAsyncContextStore(key) {
39
+ const symbol = Symbol.for(key);
40
+ const globalRecord = globalThis;
41
+ const existing = globalRecord[symbol];
42
+ if (existing) {
43
+ return existing;
44
+ }
45
+ const store = new AsyncLocalStorage;
46
+ globalRecord[symbol] = store;
47
+ return store;
48
+ }
49
+
50
+ // ../../src/core/database/connectionContext.ts
51
+ var activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
52
+ function runWithDatabaseConnection(connection, callback) {
53
+ return activeConnection.run(connection, callback);
54
+ }
55
+ function getActiveDatabaseConnection(fallback) {
56
+ return activeConnection.getStore() ?? fallback;
57
+ }
58
+ function hasActiveDatabaseConnection() {
59
+ return activeConnection.getStore() !== undefined;
60
+ }
61
+
62
+ // ../../src/core/database/queryProxy.ts
63
+ var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
64
+
65
+ // ../../src/core/database/defaultConnection.ts
66
+ var DEFAULT_CONNECTION_KEY = Symbol.for("@getstrata/defaultDatabaseConnection");
67
+ function defaultConnectionState() {
68
+ const globalRecord = globalThis;
69
+ const existing = globalRecord[DEFAULT_CONNECTION_KEY];
70
+ if (existing) {
71
+ return existing;
72
+ }
73
+ const created = { pool: null, query: null };
74
+ globalRecord[DEFAULT_CONNECTION_KEY] = created;
75
+ return created;
76
+ }
77
+ function getDefaultDatabasePool() {
78
+ const pool = defaultConnectionState().pool;
79
+ if (!pool) {
80
+ throw new Error("Default database pool is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
81
+ }
82
+ return pool;
83
+ }
84
+
85
+ // ../../src/core/database/schema/driver.ts
86
+ function normalizeConnectionName(connection) {
87
+ const normalized = connection.trim().toLowerCase();
88
+ if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
89
+ return "pgsql";
90
+ }
91
+ if (normalized === "mysql" || normalized === "mariadb") {
92
+ return "mysql";
93
+ }
94
+ if (normalized === "sqlite") {
95
+ return "sqlite";
96
+ }
97
+ throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
98
+ }
99
+ function resolveDriverFromUrl(url) {
100
+ const normalized = url.trim().toLowerCase();
101
+ if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
102
+ return "pgsql";
103
+ }
104
+ if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
105
+ return "mysql";
106
+ }
107
+ if (normalized.startsWith("sqlite:")) {
108
+ return "sqlite";
109
+ }
110
+ return null;
17
111
  }
18
- function envFlagEnabled(value) {
19
- return value === "true";
112
+ function resolveDatabaseDriver(options = {}) {
113
+ const connection = options.connection ?? process.env.DB_CONNECTION;
114
+ if (connection) {
115
+ return normalizeConnectionName(connection);
116
+ }
117
+ const url = options.url ?? process.env.DATABASE_URL ?? "";
118
+ const fromUrl = resolveDriverFromUrl(url);
119
+ if (fromUrl) {
120
+ return fromUrl;
121
+ }
122
+ return "pgsql";
20
123
  }
21
124
 
22
- // ../../src/core/runtime/appKeyPrefix.ts
23
- function appKeyPrefix() {
24
- return process.env.APP_KEY_PREFIX?.trim() || "strata";
125
+ // ../../src/core/database/dialect.ts
126
+ function assertSafeIdentifier(identifier) {
127
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
128
+ throw new Error(`Invalid SQL identifier: ${identifier}`);
129
+ }
130
+ return identifier;
131
+ }
132
+ function quoteIdentifierFor(dialect, column) {
133
+ return dialect.quoteIdentifier(column);
134
+ }
135
+ var postgresDialect = {
136
+ driver: "pgsql",
137
+ placeholder(index) {
138
+ return `$${index}`;
139
+ },
140
+ quoteIdentifier(identifier) {
141
+ return `"${assertSafeIdentifier(identifier)}"`;
142
+ },
143
+ nowExpression() {
144
+ return "NOW()";
145
+ },
146
+ timestampValue(value) {
147
+ return value.toISOString();
148
+ },
149
+ returningClause(columns) {
150
+ return ` RETURNING ${columns}`;
151
+ },
152
+ ilikeOperator() {
153
+ return "ILIKE";
154
+ },
155
+ nullsLastSuffix() {
156
+ return " NULLS LAST";
157
+ },
158
+ castToText(expression) {
159
+ return `${expression}::text`;
160
+ },
161
+ upsertSuffix(conflictColumns, updateColumns) {
162
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
163
+ if (updateColumns.length === 0) {
164
+ return ` ON CONFLICT (${target}) DO NOTHING`;
165
+ }
166
+ const assignments = updateColumns.map((column) => {
167
+ const quoted = quoteIdentifierFor(this, column);
168
+ return `${quoted} = excluded.${quoted}`;
169
+ }).join(", ");
170
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
171
+ }
172
+ };
173
+ var mysqlDialect = {
174
+ driver: "mysql",
175
+ placeholder() {
176
+ return "?";
177
+ },
178
+ quoteIdentifier(identifier) {
179
+ return `\`${assertSafeIdentifier(identifier)}\``;
180
+ },
181
+ nowExpression() {
182
+ return "CURRENT_TIMESTAMP";
183
+ },
184
+ timestampValue(value) {
185
+ return value.toISOString().slice(0, 19).replace("T", " ");
186
+ },
187
+ returningClause() {
188
+ return "";
189
+ },
190
+ ilikeOperator() {
191
+ return "LIKE";
192
+ },
193
+ nullsLastSuffix() {
194
+ return "";
195
+ },
196
+ castToText(expression) {
197
+ return `CAST(${expression} AS CHAR)`;
198
+ },
199
+ upsertSuffix(conflictColumns, updateColumns) {
200
+ if (updateColumns.length === 0) {
201
+ const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
202
+ return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
203
+ }
204
+ const assignments = updateColumns.map((column) => {
205
+ const quoted = quoteIdentifierFor(this, column);
206
+ return `${quoted} = VALUES(${quoted})`;
207
+ }).join(", ");
208
+ return ` ON DUPLICATE KEY UPDATE ${assignments}`;
209
+ }
210
+ };
211
+ var sqliteDialect = {
212
+ driver: "sqlite",
213
+ placeholder() {
214
+ return "?";
215
+ },
216
+ quoteIdentifier(identifier) {
217
+ return `"${assertSafeIdentifier(identifier)}"`;
218
+ },
219
+ nowExpression() {
220
+ return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
221
+ },
222
+ timestampValue(value) {
223
+ return value.toISOString();
224
+ },
225
+ returningClause(columns) {
226
+ return ` RETURNING ${columns}`;
227
+ },
228
+ ilikeOperator() {
229
+ return "LIKE";
230
+ },
231
+ nullsLastSuffix() {
232
+ return "";
233
+ },
234
+ castToText(expression) {
235
+ return `CAST(${expression} AS TEXT)`;
236
+ },
237
+ upsertSuffix(conflictColumns, updateColumns) {
238
+ const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
239
+ if (updateColumns.length === 0) {
240
+ return ` ON CONFLICT (${target}) DO NOTHING`;
241
+ }
242
+ const assignments = updateColumns.map((column) => {
243
+ const quoted = quoteIdentifierFor(this, column);
244
+ return `${quoted} = excluded.${quoted}`;
245
+ }).join(", ");
246
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
247
+ }
248
+ };
249
+ var dialects = {
250
+ pgsql: postgresDialect,
251
+ mysql: mysqlDialect,
252
+ sqlite: sqliteDialect
253
+ };
254
+ var dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
255
+ var dialectOverride = null;
256
+ function dialectFor(driver) {
257
+ return dialects[driver];
258
+ }
259
+ function currentSqlDialect() {
260
+ return dialectContext.getStore() ?? dialectOverride ?? dialectFor(resolveDatabaseDriver());
261
+ }
262
+
263
+ // ../../src/core/database/errors.ts
264
+ import {
265
+ BadRequestError,
266
+ ConflictError,
267
+ InternalServerError,
268
+ toHttpError,
269
+ UnprocessableEntityError
270
+ } from "@getstrata/core/errors/http";
271
+ function isPostgresError(error) {
272
+ return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
273
+ }
274
+ function getPostgresSqlState(error) {
275
+ if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
276
+ return error.errno;
277
+ }
278
+ if (typeof error.errno === "number") {
279
+ return String(error.errno).padStart(5, "0");
280
+ }
281
+ if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
282
+ return error.code;
283
+ }
284
+ return;
285
+ }
286
+ var MYSQL_ERRNO_MESSAGES = {
287
+ 1062: () => new ConflictError("A record with these values already exists."),
288
+ 1451: () => new UnprocessableEntityError("Record is still referenced by other records."),
289
+ 1452: () => new UnprocessableEntityError("Referenced record does not exist."),
290
+ 1048: () => new BadRequestError("Required field is missing."),
291
+ 3819: () => new BadRequestError("Value violates a database constraint.")
292
+ };
293
+ function mapSqliteError(error) {
294
+ const code = typeof error.code === "string" ? error.code : "";
295
+ if (!code.startsWith("SQLITE_")) {
296
+ return null;
297
+ }
298
+ if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
299
+ return new ConflictError("A record with these values already exists.");
300
+ }
301
+ if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
302
+ return new UnprocessableEntityError("Referenced record does not exist.");
303
+ }
304
+ if (code === "SQLITE_CONSTRAINT_NOTNULL") {
305
+ return new BadRequestError("Required field is missing.");
306
+ }
307
+ if (code.startsWith("SQLITE_CONSTRAINT")) {
308
+ return new BadRequestError("Value violates a database constraint.");
309
+ }
310
+ return new InternalServerError("Database operation failed.");
311
+ }
312
+ function mapMysqlError(error) {
313
+ const code = typeof error.code === "string" ? error.code : "";
314
+ if (!code.startsWith("ER_")) {
315
+ return null;
316
+ }
317
+ const factory = typeof error.errno === "number" ? MYSQL_ERRNO_MESSAGES[error.errno] : undefined;
318
+ return factory ? factory() : new InternalServerError("Database operation failed.");
25
319
  }
26
- function appCookieName(kind) {
27
- return `${appKeyPrefix()}_${kind}`;
320
+ function mapDatabaseError(error) {
321
+ const httpError = toHttpError(error);
322
+ if (httpError) {
323
+ return httpError;
324
+ }
325
+ if (!isPostgresError(error)) {
326
+ return new InternalServerError;
327
+ }
328
+ const sqlite = mapSqliteError(error);
329
+ if (sqlite) {
330
+ return sqlite;
331
+ }
332
+ const mysql = mapMysqlError(error);
333
+ if (mysql) {
334
+ return mysql;
335
+ }
336
+ const sqlState = getPostgresSqlState(error);
337
+ switch (sqlState) {
338
+ case "23505":
339
+ if (error.detail) {
340
+ console.error("[database] unique violation", error.detail);
341
+ }
342
+ return new ConflictError("A record with these values already exists.");
343
+ case "23503":
344
+ if (error.detail) {
345
+ console.error("[database] foreign-key violation", error.detail);
346
+ }
347
+ return new UnprocessableEntityError("Referenced record does not exist.");
348
+ case "23502":
349
+ if (error.detail) {
350
+ console.error("[database] not-null violation", error.detail);
351
+ }
352
+ return new BadRequestError("Required field is missing.");
353
+ case "23514":
354
+ if (error.detail) {
355
+ console.error("[database] check violation", error.detail);
356
+ }
357
+ return new BadRequestError("Value violates a database constraint.");
358
+ default:
359
+ return new InternalServerError("Database operation failed.");
360
+ }
28
361
  }
29
- function appDevSecret(kind) {
30
- return `${appKeyPrefix()}-dev-${kind}`;
362
+ async function withDatabaseErrorHandling(operation) {
363
+ try {
364
+ return await operation();
365
+ } catch (error) {
366
+ throw mapDatabaseError(error);
367
+ }
31
368
  }
32
- function requireConfiguredSecret(names, devKind, env = process.env) {
33
- for (const name of names) {
34
- const value = env[name]?.trim();
35
- if (value) {
36
- return value;
369
+ function isUniqueConstraintError(error) {
370
+ if (!isPostgresError(error)) {
371
+ return false;
372
+ }
373
+ if (error.code === "SQLITE_CONSTRAINT_UNIQUE" || error.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
374
+ return true;
375
+ }
376
+ if (error.errno === 1062) {
377
+ return true;
378
+ }
379
+ return getPostgresSqlState(error) === "23505";
380
+ }
381
+
382
+ // ../../src/core/auth/saml/samlAssertionReplay.ts
383
+ var REPLAY_RETENTION_MS = 60 * 60 * 1000;
384
+
385
+ class InMemorySamlAssertionReplayStore {
386
+ seen = new Map;
387
+ async consume(assertionId) {
388
+ this.gc();
389
+ const consumedAt = this.seen.get(assertionId);
390
+ if (consumedAt !== undefined && Date.now() - consumedAt < REPLAY_RETENTION_MS) {
391
+ throw new Error("SAML assertion replay detected.");
392
+ }
393
+ this.seen.set(assertionId, Date.now());
394
+ }
395
+ clear() {
396
+ this.seen.clear();
397
+ }
398
+ gc() {
399
+ const cutoff = Date.now() - REPLAY_RETENTION_MS;
400
+ for (const [id, at] of this.seen) {
401
+ if (at < cutoff) {
402
+ this.seen.delete(id);
403
+ }
37
404
  }
38
405
  }
39
- if (isProductionEnv(env)) {
40
- throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
406
+ }
407
+
408
+ class SqlSamlAssertionReplayStore {
409
+ async consume(assertionId) {
410
+ const sql = getBoundDatabaseConnection() ?? getDefaultDatabasePool();
411
+ const dialect = currentSqlDialect();
412
+ try {
413
+ await sql.unsafe(`INSERT INTO auth_saml_assertions (assertion_id, consumed_at) VALUES (${dialect.placeholder(1)}, ${dialect.placeholder(2)})`, [assertionId, new Date().toISOString()]);
414
+ } catch (error) {
415
+ if (isUniqueConstraintError(error)) {
416
+ throw new Error("SAML assertion replay detected.");
417
+ }
418
+ throw error;
419
+ }
420
+ await sql.unsafe(`DELETE FROM auth_saml_assertions WHERE consumed_at < ${dialect.placeholder(1)}`, [new Date(Date.now() - REPLAY_RETENTION_MS).toISOString()]);
41
421
  }
42
- return appDevSecret(devKind);
43
422
  }
44
- function namespacedRedisKey(kind) {
45
- return `${appKeyPrefix()}:${kind}`;
423
+ var sqlReplayStore = new SqlSamlAssertionReplayStore;
424
+ var replayStoreOverride = null;
425
+ function samlAssertionReplayStore() {
426
+ return replayStoreOverride ?? sqlReplayStore;
46
427
  }
47
- function smtpEhloHost() {
48
- const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
49
- const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
50
- return safe || "strata.local";
428
+ function setSamlAssertionReplayStoreForTests(store) {
429
+ replayStoreOverride = store;
51
430
  }
52
- function siemEventType() {
53
- return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
431
+ function resetSamlReplayCacheForTests() {
432
+ if (replayStoreOverride instanceof InMemorySamlAssertionReplayStore) {
433
+ replayStoreOverride.clear();
434
+ return;
435
+ }
436
+ replayStoreOverride = null;
54
437
  }
55
- function appUserAgent() {
56
- return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
438
+ async function consumeSamlAssertion(assertionId) {
439
+ await samlAssertionReplayStore().consume(assertionId);
57
440
  }
58
- function otelServiceName() {
59
- return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
441
+
442
+ // ../../src/core/auth/saml/samlServiceProvider.ts
443
+ function readAssertionId(profile) {
444
+ if (typeof profile.ID === "string" && profile.ID.trim()) {
445
+ return profile.ID.trim();
446
+ }
447
+ const parsed = typeof profile.getAssertion === "function" ? profile.getAssertion() : null;
448
+ const assertionId = parsed?.Assertion?.$?.ID;
449
+ return typeof assertionId === "string" ? assertionId.trim() : "";
60
450
  }
61
- function appDisplayName() {
62
- return process.env.APP_NAME?.trim() || "Strata";
451
+ function requireSamlOption(value, name) {
452
+ const trimmed = value?.trim() ?? "";
453
+ if (!trimmed) {
454
+ throw new Error(`${name} is required.`);
455
+ }
456
+ return trimmed;
457
+ }
458
+ function readSamlEnvOptions() {
459
+ const idpSsoUrl = process.env.SAML_IDP_SSO_URL?.trim() ?? "";
460
+ const idpCert = process.env.SAML_IDP_CERT?.trim() ?? "";
461
+ const spEntityId = process.env.SAML_SP_ENTITY_ID?.trim() ?? "";
462
+ const acsUrl = process.env.SAML_ACS_URL?.trim() ?? "";
463
+ const idpIssuer = process.env.SAML_IDP_ISSUER?.trim() ?? "";
464
+ if (!idpSsoUrl || !idpCert || !spEntityId || !acsUrl || !idpIssuer) {
465
+ throw new Error("SAML requires SAML_IDP_SSO_URL, SAML_IDP_CERT, SAML_SP_ENTITY_ID, SAML_ACS_URL, and SAML_IDP_ISSUER.");
466
+ }
467
+ return {
468
+ idpSsoUrl,
469
+ idpCert,
470
+ spEntityId,
471
+ acsUrl,
472
+ idpIssuer,
473
+ wantAuthnResponseSigned: process.env.SAML_WANT_RESPONSE_SIGNED !== "false",
474
+ disableRequestedAuthnContext: process.env.SAML_DISABLE_REQUESTED_AUTHN_CONTEXT === "true"
475
+ };
63
476
  }
64
- function appEnv() {
65
- return process.env.APP_ENV?.trim() || "local";
477
+ var nodeSamlLoader = null;
478
+ async function defaultLoadNodeSaml() {
479
+ return await import("@node-saml/node-saml");
66
480
  }
67
- function appUrl() {
68
- return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
481
+ async function loadNodeSaml() {
482
+ try {
483
+ return await (nodeSamlLoader ?? defaultLoadNodeSaml)();
484
+ } catch (error) {
485
+ throw missingOptionalPeer("@node-saml/node-saml", "to verify SAML assertions", error);
486
+ }
69
487
  }
70
- function apiPrefix() {
71
- const raw = process.env.API_PREFIX?.trim() || "/api/v1";
72
- const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
73
- const trimmed = withSlash.replace(/\/+$/, "");
74
- return trimmed || "/api/v1";
488
+ function setNodeSamlLoaderForTests(loader) {
489
+ nodeSamlLoader = loader;
75
490
  }
76
- function sdkClientClassName() {
77
- const override = process.env.APP_SDK_CLASS?.trim();
78
- if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
79
- return override;
491
+
492
+ class SamlServiceProvider {
493
+ options;
494
+ client = null;
495
+ constructor(options) {
496
+ this.options = options;
497
+ requireSamlOption(options.idpSsoUrl, "idpSsoUrl");
498
+ requireSamlOption(options.idpCert, "idpCert");
499
+ requireSamlOption(options.spEntityId, "spEntityId");
500
+ requireSamlOption(options.acsUrl, "acsUrl");
501
+ requireSamlOption(options.idpIssuer, "idpIssuer");
502
+ }
503
+ async getClient() {
504
+ if (this.client) {
505
+ return this.client;
506
+ }
507
+ const { SAML } = await loadNodeSaml();
508
+ this.client = new SAML({
509
+ callbackUrl: this.options.acsUrl,
510
+ entryPoint: this.options.idpSsoUrl,
511
+ issuer: this.options.spEntityId,
512
+ idpCert: this.options.idpCert,
513
+ audience: this.options.spEntityId,
514
+ idpIssuer: this.options.idpIssuer,
515
+ acceptedClockSkewMs: 5000,
516
+ wantAssertionsSigned: this.options.wantAssertionsSigned ?? true,
517
+ wantAuthnResponseSigned: this.options.wantAuthnResponseSigned ?? true,
518
+ disableRequestedAuthnContext: this.options.disableRequestedAuthnContext ?? false
519
+ });
520
+ return this.client;
80
521
  }
81
- const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
82
- return fromName ? `${fromName}Client` : "AppClient";
522
+ async authorizationUrl(relayState) {
523
+ const client = await this.getClient();
524
+ return await client.getAuthorizeUrlAsync(relayState, undefined, {});
525
+ }
526
+ async consumePost(samlResponse, relayState) {
527
+ if (!samlResponse.trim()) {
528
+ throw new Error("SAML response is required.");
529
+ }
530
+ const client = await this.getClient();
531
+ const { profile, loggedOut } = await client.validatePostResponseAsync({
532
+ SAMLResponse: samlResponse,
533
+ ...relayState ? { RelayState: relayState } : {}
534
+ });
535
+ if (loggedOut || !profile) {
536
+ throw new Error("SAML assertion did not contain a signed user profile.");
537
+ }
538
+ const issuer = typeof profile.issuer === "string" ? profile.issuer.trim() : "";
539
+ if (!issuer || !timingSafeCompareString(issuer, this.options.idpIssuer)) {
540
+ throw new Error("SAML assertion issuer is invalid.");
541
+ }
542
+ const assertionId = readAssertionId(profile);
543
+ if (!assertionId) {
544
+ throw new Error("SAML assertion did not include an ID.");
545
+ }
546
+ await consumeSamlAssertion(assertionId);
547
+ const email = profile.email || (typeof profile["urn:oid:0.9.2342.19200300.100.1.3"] === "string" ? profile["urn:oid:0.9.2342.19200300.100.1.3"] : "") || "";
548
+ if (!email) {
549
+ throw new Error("SAML assertion did not include an email address.");
550
+ }
551
+ return {
552
+ providerUserId: profile.nameID || email,
553
+ email,
554
+ name: typeof profile.name === "string" && profile.name.trim() ? profile.name : email
555
+ };
556
+ }
557
+ }
558
+ function createSamlServiceProvider(options = readSamlEnvOptions()) {
559
+ return new SamlServiceProvider(options);
83
560
  }
84
561
 
85
562
  // ../../src/core/auth/oauth/samlProvider.ts
86
563
  class SamlProvider {
87
- loginUrl;
88
564
  name = "saml";
89
- constructor(loginUrl) {
90
- this.loginUrl = loginUrl;
565
+ provider;
566
+ constructor(options) {
567
+ if (typeof options === "string") {
568
+ throw new Error("SamlProvider now requires IdP metadata (idpSsoUrl, idpCert, spEntityId, acsUrl, idpIssuer). The string constructor login stub has been removed.");
569
+ }
570
+ this.provider = createSamlServiceProvider(options);
91
571
  }
92
- getAuthorizationUrl(state) {
93
- return `${this.loginUrl}?state=${encodeURIComponent(state)}`;
572
+ getAuthorizationUrl(_state) {
573
+ throw new Error("SAML authorization is async. Use SamlServiceProvider.authorizationUrl().");
94
574
  }
95
- async exchangeCode(code) {
96
- if (!code.startsWith("saml:")) {
97
- throw new Error("Invalid SAML assertion reference.");
98
- }
99
- const [, email, name] = code.split(":");
100
- return {
101
- providerUserId: email || "saml-user",
102
- email: email || `saml-user@${appKeyPrefix()}.test`,
103
- name: name ?? "SAML User"
104
- };
575
+ async exchangeCode(_code) {
576
+ throw new Error("SAML login uses consumePost() with a signed SAMLResponse, not an OAuth code.");
577
+ }
578
+ authorizationUrl(relayState) {
579
+ return this.provider.authorizationUrl(relayState);
580
+ }
581
+ consumePost(samlResponse, relayState) {
582
+ return this.provider.consumePost(samlResponse, relayState);
105
583
  }
106
584
  }
107
585
  export {