@getstrata/core 1.0.9 → 1.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 +48 -0
- package/dist/core/auth/authContext.d.ts +5 -2
- package/dist/core/auth/emailVerification.d.ts +1 -1
- package/dist/core/auth/guard.d.ts +5 -1
- package/dist/core/auth/jwtGuard.d.ts +6 -2
- package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
- package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
- package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
- package/dist/core/auth/oneTimeToken.d.ts +19 -0
- package/dist/core/auth/passwordLogin.d.ts +20 -0
- package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
- package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
- package/dist/core/contracts/authUserDirectory.d.ts +2 -3
- package/dist/core/database/errors.d.ts +2 -1
- package/dist/core/http/response.d.ts +3 -1
- package/dist/core/mail/mailer.d.ts +6 -1
- package/dist/core/security/oauthState.d.ts +6 -2
- package/dist/core/security/safeFetch.d.ts +1 -0
- package/dist/core/security/safePath.d.ts +4 -0
- package/dist/core/security/safeUrl.d.ts +14 -1
- package/dist/core/security/totp.d.ts +1 -1
- package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
- package/dist/core/tenant/enableTenantRls.d.ts +6 -0
- package/dist/core/tenant/postgresAppRole.d.ts +75 -0
- package/dist/entries/audit/exportAuditLogs.js +223 -40
- package/dist/entries/auth/accessControl.js +1 -0
- package/dist/entries/auth/basicAuthGuard.js +638 -17
- package/dist/entries/auth/emailVerification.js +1 -1
- package/dist/entries/auth/intendedUrlCookie.js +32 -2
- package/dist/entries/auth/jwt.js +1 -1
- package/dist/entries/auth/jwtGuard.js +192 -3
- package/dist/entries/auth/membershipMiddleware.js +1 -0
- package/dist/entries/auth/membershipScope.js +1 -0
- package/dist/entries/auth/membershipService.js +1 -0
- package/dist/entries/auth/oauth/oidcProvider.js +542 -20
- package/dist/entries/auth/oauth/providers.js +313 -5
- package/dist/entries/auth/oauth/samlProvider.js +552 -74
- package/dist/entries/auth/oneTimeToken.js +362 -0
- package/dist/entries/auth/password.js +1 -1
- package/dist/entries/auth/passwordLogin.js +548 -0
- package/dist/entries/auth/policy.js +1 -0
- package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
- package/dist/entries/auth/scimAuthMiddleware.js +10 -3
- package/dist/entries/auth/sessionCookie.js +1 -1
- package/dist/entries/auth/sessionGuard.js +1 -1
- package/dist/entries/auth/tokenHash.js +2 -6
- package/dist/entries/crypto/fieldEncryption.js +1 -1
- package/dist/entries/crypto/mfaSecret.js +31 -5
- package/dist/entries/database/errors.js +29 -12
- package/dist/entries/database/model.js +9 -9
- package/dist/entries/facades.js +72 -9
- package/dist/entries/http/authMiddleware.js +7 -14
- package/dist/entries/http/corsMiddleware.js +69 -2
- package/dist/entries/http/csrfMiddleware.js +33 -43
- package/dist/entries/http/csrfToken.js +2 -2
- package/dist/entries/http/flashMiddleware.js +4 -2
- package/dist/entries/http/flashSession.js +4 -2
- package/dist/entries/http/formRequest.js +2 -2
- package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
- package/dist/entries/http/pagination.js +2 -2
- package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
- package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
- package/dist/entries/http/response.js +101 -45
- package/dist/entries/http/routeModelBinding.js +2 -2
- package/dist/entries/http/safeInternalPath.js +32 -2
- package/dist/entries/http/scimThrottleMiddleware.js +7 -1
- package/dist/entries/http/securedRouteModelBinding.js +2 -2
- package/dist/entries/http/securityHeadersMiddleware.js +3 -64
- package/dist/entries/http/signedUrl.js +6 -5
- package/dist/entries/http/throttleMiddleware.js +63 -0
- package/dist/entries/http/validation.js +2 -2
- package/dist/entries/http/webFormRequest.js +2 -2
- package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
- package/dist/entries/mail/mailer.js +54 -8
- package/dist/entries/media/imageTransform.js +12 -1
- package/dist/entries/openapi/generator.js +0 -7
- package/dist/entries/security/oauthState.js +30 -39
- package/dist/entries/security/publicReads.js +1 -1
- package/dist/entries/security/recoveryCodes.js +2 -2
- package/dist/entries/security/safeFetch.js +195 -94
- package/dist/entries/security/safePath.js +25 -0
- package/dist/entries/security/scimTenantTokens.js +10 -3
- package/dist/entries/security/totp.js +16 -4
- package/dist/entries/storage/storage.js +24 -2
- package/dist/entries/tenant/databaseTenantContext.js +34 -12
- package/dist/entries/tenant/enableTenantRls.js +385 -0
- package/dist/entries/tracing/tracingMiddleware.js +308 -5
- package/dist/framework/public-api.d.ts +10 -3
- package/dist/index.js +2767 -1515
- package/package.json +32 -3
|
@@ -44,6 +44,71 @@ function resolveAuthUserDirectory(container) {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ../../src/core/database/boundConnection.ts
|
|
48
|
+
var BOUND_CONNECTION_KEY = Symbol.for("@getstrata/boundDatabaseConnection");
|
|
49
|
+
function boundConnectionState() {
|
|
50
|
+
const globalRecord = globalThis;
|
|
51
|
+
const existing = globalRecord[BOUND_CONNECTION_KEY];
|
|
52
|
+
if (existing) {
|
|
53
|
+
return existing;
|
|
54
|
+
}
|
|
55
|
+
const created = { connection: null };
|
|
56
|
+
globalRecord[BOUND_CONNECTION_KEY] = created;
|
|
57
|
+
return created;
|
|
58
|
+
}
|
|
59
|
+
function getBoundDatabaseConnection() {
|
|
60
|
+
return boundConnectionState().connection;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ../../src/core/runtime/asyncContextStore.ts
|
|
64
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
65
|
+
function createAsyncContextStore(key) {
|
|
66
|
+
const symbol = Symbol.for(key);
|
|
67
|
+
const globalRecord = globalThis;
|
|
68
|
+
const existing = globalRecord[symbol];
|
|
69
|
+
if (existing) {
|
|
70
|
+
return existing;
|
|
71
|
+
}
|
|
72
|
+
const store = new AsyncLocalStorage;
|
|
73
|
+
globalRecord[symbol] = store;
|
|
74
|
+
return store;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ../../src/core/database/connectionContext.ts
|
|
78
|
+
var activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
|
|
79
|
+
function runWithDatabaseConnection(connection, callback) {
|
|
80
|
+
return activeConnection.run(connection, callback);
|
|
81
|
+
}
|
|
82
|
+
function getActiveDatabaseConnection(fallback) {
|
|
83
|
+
return activeConnection.getStore() ?? fallback;
|
|
84
|
+
}
|
|
85
|
+
function hasActiveDatabaseConnection() {
|
|
86
|
+
return activeConnection.getStore() !== undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ../../src/core/database/queryProxy.ts
|
|
90
|
+
var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
|
|
91
|
+
|
|
92
|
+
// ../../src/core/database/defaultConnection.ts
|
|
93
|
+
var DEFAULT_CONNECTION_KEY = Symbol.for("@getstrata/defaultDatabaseConnection");
|
|
94
|
+
function defaultConnectionState() {
|
|
95
|
+
const globalRecord = globalThis;
|
|
96
|
+
const existing = globalRecord[DEFAULT_CONNECTION_KEY];
|
|
97
|
+
if (existing) {
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
const created = { pool: null, query: null };
|
|
101
|
+
globalRecord[DEFAULT_CONNECTION_KEY] = created;
|
|
102
|
+
return created;
|
|
103
|
+
}
|
|
104
|
+
function getDefaultDatabasePool() {
|
|
105
|
+
const pool = defaultConnectionState().pool;
|
|
106
|
+
if (!pool) {
|
|
107
|
+
throw new Error("Default database pool is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
|
|
108
|
+
}
|
|
109
|
+
return pool;
|
|
110
|
+
}
|
|
111
|
+
|
|
47
112
|
// ../../src/core/http/statelessAuth.ts
|
|
48
113
|
function authorizationScheme(request) {
|
|
49
114
|
const header = request.headers.get("authorization")?.trim() ?? "";
|
|
@@ -90,13 +155,542 @@ function readBasicCredentials(request) {
|
|
|
90
155
|
async function hashPassword(password) {
|
|
91
156
|
return await Bun.password.hash(password, {
|
|
92
157
|
algorithm: "bcrypt",
|
|
93
|
-
cost:
|
|
158
|
+
cost: 12
|
|
94
159
|
});
|
|
95
160
|
}
|
|
96
161
|
async function verifyPassword(password, passwordHash) {
|
|
97
162
|
return await Bun.password.verify(password, passwordHash);
|
|
98
163
|
}
|
|
99
164
|
|
|
165
|
+
// ../../src/core/runtime/appEnv.ts
|
|
166
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
167
|
+
function normalizeEnvValue(value) {
|
|
168
|
+
return (value ?? "").trim().toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
function isProductionEnv(env = process.env) {
|
|
171
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
172
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
173
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (appEnv === "") {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
180
|
+
}
|
|
181
|
+
function envFlagEnabled(value) {
|
|
182
|
+
return value === "true";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ../../src/core/crypto/fieldEncryption.ts
|
|
186
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
|
|
187
|
+
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
188
|
+
var IV_LENGTH = 12;
|
|
189
|
+
var TAG_LENGTH = 16;
|
|
190
|
+
function resolveEncryptionKey() {
|
|
191
|
+
const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
|
|
192
|
+
if (!raw) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (/^[0-9a-f]{64}$/i.test(raw)) {
|
|
196
|
+
return Buffer.from(raw, "hex");
|
|
197
|
+
}
|
|
198
|
+
const decoded = Buffer.from(raw, "base64");
|
|
199
|
+
if (decoded.length === 32) {
|
|
200
|
+
return decoded;
|
|
201
|
+
}
|
|
202
|
+
throw new Error("KMS_ENCRYPTION_KEY must be 32 bytes (hex or base64).");
|
|
203
|
+
}
|
|
204
|
+
function isFieldEncryptionEnabled() {
|
|
205
|
+
const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
|
|
206
|
+
if (featureFlag === "false") {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (featureFlag === "true") {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return Boolean(resolveEncryptionKey());
|
|
213
|
+
}
|
|
214
|
+
function encryptField(plaintext, key) {
|
|
215
|
+
const iv = randomBytes(IV_LENGTH);
|
|
216
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
217
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
218
|
+
const tag = cipher.getAuthTag();
|
|
219
|
+
const payload = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
220
|
+
return `${ENCRYPTION_PREFIX}${payload}`;
|
|
221
|
+
}
|
|
222
|
+
function decryptField(value, key) {
|
|
223
|
+
if (!value.startsWith(ENCRYPTION_PREFIX)) {
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
|
227
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
228
|
+
const tag = payload.subarray(payload.length - TAG_LENGTH);
|
|
229
|
+
const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
|
|
230
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
231
|
+
decipher.setAuthTag(tag);
|
|
232
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
233
|
+
}
|
|
234
|
+
function hashLookupValue(normalizedValue, key) {
|
|
235
|
+
return createHmac("sha256", key).update(normalizedValue).digest("hex");
|
|
236
|
+
}
|
|
237
|
+
function normalizeEmail(email) {
|
|
238
|
+
return email.trim().toLowerCase();
|
|
239
|
+
}
|
|
240
|
+
function protectEmail(email) {
|
|
241
|
+
const normalized = normalizeEmail(email);
|
|
242
|
+
const key = resolveEncryptionKey();
|
|
243
|
+
if (!key || !isFieldEncryptionEnabled()) {
|
|
244
|
+
return { storedEmail: normalized, emailLookup: normalized };
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
storedEmail: encryptField(normalized, key),
|
|
248
|
+
emailLookup: hashLookupValue(normalized, key)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function revealEmail(storedEmail) {
|
|
252
|
+
const key = resolveEncryptionKey();
|
|
253
|
+
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
254
|
+
return storedEmail;
|
|
255
|
+
}
|
|
256
|
+
return decryptField(storedEmail, key);
|
|
257
|
+
}
|
|
258
|
+
function emailLookupForQuery(email) {
|
|
259
|
+
const normalized = normalizeEmail(email);
|
|
260
|
+
const key = resolveEncryptionKey();
|
|
261
|
+
if (!key || !isFieldEncryptionEnabled()) {
|
|
262
|
+
return normalized;
|
|
263
|
+
}
|
|
264
|
+
return hashLookupValue(normalized, key);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ../../src/core/crypto/mfaSecret.ts
|
|
268
|
+
function protectMfaSecret(secret) {
|
|
269
|
+
const key = resolveEncryptionKey();
|
|
270
|
+
if (!key) {
|
|
271
|
+
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
272
|
+
}
|
|
273
|
+
return encryptField(secret, key);
|
|
274
|
+
}
|
|
275
|
+
function revealMfaSecret(stored) {
|
|
276
|
+
if (!stored) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
if (!stored.startsWith("enc:v1:")) {
|
|
280
|
+
if (isProductionEnv() || resolveEncryptionKey()) {
|
|
281
|
+
throw new Error("MFA secrets must be encrypted.");
|
|
282
|
+
}
|
|
283
|
+
return stored;
|
|
284
|
+
}
|
|
285
|
+
const key = resolveEncryptionKey();
|
|
286
|
+
if (!key) {
|
|
287
|
+
throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
|
|
288
|
+
}
|
|
289
|
+
return decryptField(stored, key);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ../../src/core/database/schema/driver.ts
|
|
293
|
+
function normalizeConnectionName(connection) {
|
|
294
|
+
const normalized = connection.trim().toLowerCase();
|
|
295
|
+
if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
|
|
296
|
+
return "pgsql";
|
|
297
|
+
}
|
|
298
|
+
if (normalized === "mysql" || normalized === "mariadb") {
|
|
299
|
+
return "mysql";
|
|
300
|
+
}
|
|
301
|
+
if (normalized === "sqlite") {
|
|
302
|
+
return "sqlite";
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
|
|
305
|
+
}
|
|
306
|
+
function resolveDriverFromUrl(url) {
|
|
307
|
+
const normalized = url.trim().toLowerCase();
|
|
308
|
+
if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
|
|
309
|
+
return "pgsql";
|
|
310
|
+
}
|
|
311
|
+
if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
|
|
312
|
+
return "mysql";
|
|
313
|
+
}
|
|
314
|
+
if (normalized.startsWith("sqlite:")) {
|
|
315
|
+
return "sqlite";
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
function resolveDatabaseDriver(options = {}) {
|
|
320
|
+
const connection = options.connection ?? process.env.DB_CONNECTION;
|
|
321
|
+
if (connection) {
|
|
322
|
+
return normalizeConnectionName(connection);
|
|
323
|
+
}
|
|
324
|
+
const url = options.url ?? process.env.DATABASE_URL ?? "";
|
|
325
|
+
const fromUrl = resolveDriverFromUrl(url);
|
|
326
|
+
if (fromUrl) {
|
|
327
|
+
return fromUrl;
|
|
328
|
+
}
|
|
329
|
+
return "pgsql";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ../../src/core/database/dialect.ts
|
|
333
|
+
function assertSafeIdentifier(identifier) {
|
|
334
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
335
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
336
|
+
}
|
|
337
|
+
return identifier;
|
|
338
|
+
}
|
|
339
|
+
function quoteIdentifierFor(dialect, column) {
|
|
340
|
+
return dialect.quoteIdentifier(column);
|
|
341
|
+
}
|
|
342
|
+
var postgresDialect = {
|
|
343
|
+
driver: "pgsql",
|
|
344
|
+
placeholder(index) {
|
|
345
|
+
return `$${index}`;
|
|
346
|
+
},
|
|
347
|
+
quoteIdentifier(identifier) {
|
|
348
|
+
return `"${assertSafeIdentifier(identifier)}"`;
|
|
349
|
+
},
|
|
350
|
+
nowExpression() {
|
|
351
|
+
return "NOW()";
|
|
352
|
+
},
|
|
353
|
+
timestampValue(value) {
|
|
354
|
+
return value.toISOString();
|
|
355
|
+
},
|
|
356
|
+
returningClause(columns) {
|
|
357
|
+
return ` RETURNING ${columns}`;
|
|
358
|
+
},
|
|
359
|
+
ilikeOperator() {
|
|
360
|
+
return "ILIKE";
|
|
361
|
+
},
|
|
362
|
+
nullsLastSuffix() {
|
|
363
|
+
return " NULLS LAST";
|
|
364
|
+
},
|
|
365
|
+
castToText(expression) {
|
|
366
|
+
return `${expression}::text`;
|
|
367
|
+
},
|
|
368
|
+
upsertSuffix(conflictColumns, updateColumns) {
|
|
369
|
+
const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
|
|
370
|
+
if (updateColumns.length === 0) {
|
|
371
|
+
return ` ON CONFLICT (${target}) DO NOTHING`;
|
|
372
|
+
}
|
|
373
|
+
const assignments = updateColumns.map((column) => {
|
|
374
|
+
const quoted = quoteIdentifierFor(this, column);
|
|
375
|
+
return `${quoted} = excluded.${quoted}`;
|
|
376
|
+
}).join(", ");
|
|
377
|
+
return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
var mysqlDialect = {
|
|
381
|
+
driver: "mysql",
|
|
382
|
+
placeholder() {
|
|
383
|
+
return "?";
|
|
384
|
+
},
|
|
385
|
+
quoteIdentifier(identifier) {
|
|
386
|
+
return `\`${assertSafeIdentifier(identifier)}\``;
|
|
387
|
+
},
|
|
388
|
+
nowExpression() {
|
|
389
|
+
return "CURRENT_TIMESTAMP";
|
|
390
|
+
},
|
|
391
|
+
timestampValue(value) {
|
|
392
|
+
return value.toISOString().slice(0, 19).replace("T", " ");
|
|
393
|
+
},
|
|
394
|
+
returningClause() {
|
|
395
|
+
return "";
|
|
396
|
+
},
|
|
397
|
+
ilikeOperator() {
|
|
398
|
+
return "LIKE";
|
|
399
|
+
},
|
|
400
|
+
nullsLastSuffix() {
|
|
401
|
+
return "";
|
|
402
|
+
},
|
|
403
|
+
castToText(expression) {
|
|
404
|
+
return `CAST(${expression} AS CHAR)`;
|
|
405
|
+
},
|
|
406
|
+
upsertSuffix(conflictColumns, updateColumns) {
|
|
407
|
+
if (updateColumns.length === 0) {
|
|
408
|
+
const anchor = quoteIdentifierFor(this, conflictColumns[0] ?? "");
|
|
409
|
+
return ` ON DUPLICATE KEY UPDATE ${anchor} = ${anchor}`;
|
|
410
|
+
}
|
|
411
|
+
const assignments = updateColumns.map((column) => {
|
|
412
|
+
const quoted = quoteIdentifierFor(this, column);
|
|
413
|
+
return `${quoted} = VALUES(${quoted})`;
|
|
414
|
+
}).join(", ");
|
|
415
|
+
return ` ON DUPLICATE KEY UPDATE ${assignments}`;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
var sqliteDialect = {
|
|
419
|
+
driver: "sqlite",
|
|
420
|
+
placeholder() {
|
|
421
|
+
return "?";
|
|
422
|
+
},
|
|
423
|
+
quoteIdentifier(identifier) {
|
|
424
|
+
return `"${assertSafeIdentifier(identifier)}"`;
|
|
425
|
+
},
|
|
426
|
+
nowExpression() {
|
|
427
|
+
return "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
|
|
428
|
+
},
|
|
429
|
+
timestampValue(value) {
|
|
430
|
+
return value.toISOString();
|
|
431
|
+
},
|
|
432
|
+
returningClause(columns) {
|
|
433
|
+
return ` RETURNING ${columns}`;
|
|
434
|
+
},
|
|
435
|
+
ilikeOperator() {
|
|
436
|
+
return "LIKE";
|
|
437
|
+
},
|
|
438
|
+
nullsLastSuffix() {
|
|
439
|
+
return "";
|
|
440
|
+
},
|
|
441
|
+
castToText(expression) {
|
|
442
|
+
return `CAST(${expression} AS TEXT)`;
|
|
443
|
+
},
|
|
444
|
+
upsertSuffix(conflictColumns, updateColumns) {
|
|
445
|
+
const target = conflictColumns.map((column) => quoteIdentifierFor(this, column)).join(", ");
|
|
446
|
+
if (updateColumns.length === 0) {
|
|
447
|
+
return ` ON CONFLICT (${target}) DO NOTHING`;
|
|
448
|
+
}
|
|
449
|
+
const assignments = updateColumns.map((column) => {
|
|
450
|
+
const quoted = quoteIdentifierFor(this, column);
|
|
451
|
+
return `${quoted} = excluded.${quoted}`;
|
|
452
|
+
}).join(", ");
|
|
453
|
+
return ` ON CONFLICT (${target}) DO UPDATE SET ${assignments}`;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
var dialects = {
|
|
457
|
+
pgsql: postgresDialect,
|
|
458
|
+
mysql: mysqlDialect,
|
|
459
|
+
sqlite: sqliteDialect
|
|
460
|
+
};
|
|
461
|
+
var dialectContext = createAsyncContextStore("@getstrata/sqlDialect");
|
|
462
|
+
var dialectOverride = null;
|
|
463
|
+
function dialectFor(driver) {
|
|
464
|
+
return dialects[driver];
|
|
465
|
+
}
|
|
466
|
+
function currentSqlDialect() {
|
|
467
|
+
return dialectContext.getStore() ?? dialectOverride ?? dialectFor(resolveDatabaseDriver());
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ../../src/core/security/recoveryCodes.ts
|
|
471
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
472
|
+
import { hashApiToken } from "@getstrata/core/auth/tokenHash";
|
|
473
|
+
import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
|
|
474
|
+
var DEFAULT_RECOVERY_CODE_COUNT = 8;
|
|
475
|
+
var RECOVERY_CODE_BYTES = 16;
|
|
476
|
+
function normalizeRecoveryCode(code) {
|
|
477
|
+
return code.replace(/[^a-z0-9]/giu, "").toLowerCase();
|
|
478
|
+
}
|
|
479
|
+
function formatRecoveryCode(normalized) {
|
|
480
|
+
if (normalized.length !== RECOVERY_CODE_BYTES * 2) {
|
|
481
|
+
return normalized;
|
|
482
|
+
}
|
|
483
|
+
return normalized.match(/.{1,4}/gu)?.join("-") ?? normalized;
|
|
484
|
+
}
|
|
485
|
+
function generateRecoveryCodes(count = DEFAULT_RECOVERY_CODE_COUNT) {
|
|
486
|
+
const size = Number.isInteger(count) && count > 0 ? count : DEFAULT_RECOVERY_CODE_COUNT;
|
|
487
|
+
const codes = new Set;
|
|
488
|
+
while (codes.size < size) {
|
|
489
|
+
codes.add(formatRecoveryCode(randomBytes2(RECOVERY_CODE_BYTES).toString("hex")));
|
|
490
|
+
}
|
|
491
|
+
return [...codes];
|
|
492
|
+
}
|
|
493
|
+
function hashRecoveryCode(code) {
|
|
494
|
+
return hashApiToken(normalizeRecoveryCode(code));
|
|
495
|
+
}
|
|
496
|
+
function recoveryCodeMatches(code, hash) {
|
|
497
|
+
return timingSafeCompareString(hashRecoveryCode(code), hash);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ../../src/core/security/totp.ts
|
|
501
|
+
import { createHmac as createHmac2, randomBytes as randomBytes3 } from "crypto";
|
|
502
|
+
|
|
503
|
+
// ../../src/core/runtime/appKeyPrefix.ts
|
|
504
|
+
function appKeyPrefix() {
|
|
505
|
+
return process.env.APP_KEY_PREFIX?.trim() || "strata";
|
|
506
|
+
}
|
|
507
|
+
function appCookieName(kind) {
|
|
508
|
+
return `${appKeyPrefix()}_${kind}`;
|
|
509
|
+
}
|
|
510
|
+
function appDevSecret(kind) {
|
|
511
|
+
return `${appKeyPrefix()}-dev-${kind}`;
|
|
512
|
+
}
|
|
513
|
+
function requireConfiguredSecret(names, devKind, env = process.env) {
|
|
514
|
+
for (const name of names) {
|
|
515
|
+
const value = env[name]?.trim();
|
|
516
|
+
if (value) {
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (isProductionEnv(env)) {
|
|
521
|
+
throw new Error(`${names[0]} must be set outside development. Do not derive secrets from the app name.`);
|
|
522
|
+
}
|
|
523
|
+
return appDevSecret(devKind);
|
|
524
|
+
}
|
|
525
|
+
function namespacedRedisKey(kind) {
|
|
526
|
+
return `${appKeyPrefix()}:${kind}`;
|
|
527
|
+
}
|
|
528
|
+
function smtpEhloHost() {
|
|
529
|
+
const raw = process.env.MAIL_EHLO?.trim() || `${appKeyPrefix()}.local`;
|
|
530
|
+
const safe = raw.replace(/[^a-zA-Z0-9.-]/g, "");
|
|
531
|
+
return safe || "strata.local";
|
|
532
|
+
}
|
|
533
|
+
function siemEventType() {
|
|
534
|
+
return process.env.SIEM_EVENT_TYPE?.trim() || `${appKeyPrefix()}.audit`;
|
|
535
|
+
}
|
|
536
|
+
function appUserAgent() {
|
|
537
|
+
return process.env.APP_USER_AGENT?.trim() || appKeyPrefix();
|
|
538
|
+
}
|
|
539
|
+
function otelServiceName() {
|
|
540
|
+
return process.env.OTEL_SERVICE_NAME?.trim() || `${appKeyPrefix()}-api`;
|
|
541
|
+
}
|
|
542
|
+
function appDisplayName() {
|
|
543
|
+
return process.env.APP_NAME?.trim() || "Strata";
|
|
544
|
+
}
|
|
545
|
+
function appEnv() {
|
|
546
|
+
return process.env.APP_ENV?.trim() || "local";
|
|
547
|
+
}
|
|
548
|
+
function appUrl() {
|
|
549
|
+
return (process.env.APP_URL?.trim() || "http://localhost:3000").replace(/\/$/, "");
|
|
550
|
+
}
|
|
551
|
+
function apiPrefix() {
|
|
552
|
+
const raw = process.env.API_PREFIX?.trim() || "/api/v1";
|
|
553
|
+
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
|
554
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
555
|
+
return trimmed || "/api/v1";
|
|
556
|
+
}
|
|
557
|
+
function sdkClientClassName() {
|
|
558
|
+
const override = process.env.APP_SDK_CLASS?.trim();
|
|
559
|
+
if (override && /^[A-Za-z_][A-Za-z0-9_]*$/.test(override)) {
|
|
560
|
+
return override;
|
|
561
|
+
}
|
|
562
|
+
const fromName = appDisplayName().replace(/[^A-Za-z0-9]/g, "");
|
|
563
|
+
return fromName ? `${fromName}Client` : "AppClient";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ../../src/core/security/timingSafeCompare.ts
|
|
567
|
+
import { timingSafeEqual } from "crypto";
|
|
568
|
+
function timingSafeCompareString2(left, right) {
|
|
569
|
+
const leftBuffer = Buffer.from(left);
|
|
570
|
+
const rightBuffer = Buffer.from(right);
|
|
571
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ../../src/core/security/totp.ts
|
|
578
|
+
function decodeBase32(input) {
|
|
579
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
580
|
+
const normalized = input.replace(/=+$/u, "").toUpperCase();
|
|
581
|
+
let bits = "";
|
|
582
|
+
for (const char of normalized) {
|
|
583
|
+
const value = alphabet.indexOf(char);
|
|
584
|
+
if (value === -1) {
|
|
585
|
+
throw new Error("Invalid base32 character in MFA secret.");
|
|
586
|
+
}
|
|
587
|
+
bits += value.toString(2).padStart(5, "0");
|
|
588
|
+
}
|
|
589
|
+
const bytes = [];
|
|
590
|
+
for (let index = 0;index + 8 <= bits.length; index += 8) {
|
|
591
|
+
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
|
592
|
+
}
|
|
593
|
+
return Buffer.from(bytes);
|
|
594
|
+
}
|
|
595
|
+
function encodeBase32(bytes) {
|
|
596
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
597
|
+
let bits = "";
|
|
598
|
+
for (const byte of bytes) {
|
|
599
|
+
bits += byte.toString(2).padStart(8, "0");
|
|
600
|
+
}
|
|
601
|
+
let output = "";
|
|
602
|
+
for (let index = 0;index < bits.length; index += 5) {
|
|
603
|
+
const chunk = bits.slice(index, index + 5).padEnd(5, "0");
|
|
604
|
+
output += alphabet[Number.parseInt(chunk, 2)] ?? "";
|
|
605
|
+
}
|
|
606
|
+
return output;
|
|
607
|
+
}
|
|
608
|
+
function generateTotpSecret(byteLength = 20) {
|
|
609
|
+
return encodeBase32(randomBytes3(byteLength));
|
|
610
|
+
}
|
|
611
|
+
function buildOtpauthUrl(options) {
|
|
612
|
+
const issuer = options.issuer?.trim() || appDisplayName();
|
|
613
|
+
const label = `${issuer}:${options.account}`;
|
|
614
|
+
const params = new URLSearchParams({
|
|
615
|
+
secret: options.secret,
|
|
616
|
+
issuer,
|
|
617
|
+
algorithm: "SHA1",
|
|
618
|
+
digits: "6",
|
|
619
|
+
period: "30"
|
|
620
|
+
});
|
|
621
|
+
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
|
622
|
+
}
|
|
623
|
+
function generateTotp(secret, counter = Math.floor(Date.now() / 30000), digits = 6) {
|
|
624
|
+
const key = decodeBase32(secret);
|
|
625
|
+
const buffer = Buffer.alloc(8);
|
|
626
|
+
buffer.writeBigUInt64BE(BigInt(counter));
|
|
627
|
+
const digest = createHmac2("sha1", key).update(buffer).digest();
|
|
628
|
+
const lastByte = digest[digest.length - 1] ?? 0;
|
|
629
|
+
const offset = lastByte & 15;
|
|
630
|
+
const b0 = digest[offset] ?? 0;
|
|
631
|
+
const b1 = digest[offset + 1] ?? 0;
|
|
632
|
+
const b2 = digest[offset + 2] ?? 0;
|
|
633
|
+
const b3 = digest[offset + 3] ?? 0;
|
|
634
|
+
const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
|
|
635
|
+
return String(code % 10 ** digits).padStart(digits, "0");
|
|
636
|
+
}
|
|
637
|
+
function verifyTotp(secret, token, window = 1) {
|
|
638
|
+
const normalized = token.trim();
|
|
639
|
+
if (!/^\d{6}$/u.test(normalized)) {
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
const timestep = Math.floor(Date.now() / 30000);
|
|
643
|
+
let matched = false;
|
|
644
|
+
for (let offset = -window;offset <= window; offset += 1) {
|
|
645
|
+
if (timingSafeCompareString2(generateTotp(secret, timestep + offset), normalized)) {
|
|
646
|
+
matched = true;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return matched;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ../../src/core/auth/passwordLogin.ts
|
|
653
|
+
function parseRecoveryHashes(raw) {
|
|
654
|
+
if (!raw?.trim()) {
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const parsed = JSON.parse(raw);
|
|
659
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
|
|
660
|
+
} catch {
|
|
661
|
+
return [];
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function completePasswordLogin(user, options = {}) {
|
|
665
|
+
if (!user.mfa_enabled) {
|
|
666
|
+
return { ok: true };
|
|
667
|
+
}
|
|
668
|
+
const submitted = options.mfaCode?.trim() ?? "";
|
|
669
|
+
if (!submitted) {
|
|
670
|
+
return { ok: false, error: "mfa_required" };
|
|
671
|
+
}
|
|
672
|
+
const secret = revealMfaSecret(user.mfa_secret);
|
|
673
|
+
if (secret && verifyTotp(secret, submitted)) {
|
|
674
|
+
return { ok: true };
|
|
675
|
+
}
|
|
676
|
+
const hashes = parseRecoveryHashes(user.mfa_recovery_codes);
|
|
677
|
+
const matched = hashes.find((hash) => recoveryCodeMatches(submitted, hash));
|
|
678
|
+
if (matched) {
|
|
679
|
+
return { ok: true, consumedRecoveryHash: matched };
|
|
680
|
+
}
|
|
681
|
+
return { ok: false, error: "mfa_invalid" };
|
|
682
|
+
}
|
|
683
|
+
async function persistConsumedRecoveryHash(sql, userId, currentRaw, consumedHash) {
|
|
684
|
+
if (!consumedHash) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const dialect = currentSqlDialect();
|
|
688
|
+
await sql.unsafe(`UPDATE users SET mfa_recovery_codes = ${dialect.placeholder(1)} WHERE id = ${dialect.placeholder(2)}`, [
|
|
689
|
+
JSON.stringify(parseRecoveryHashes(currentRaw).filter((hash) => hash !== consumedHash)),
|
|
690
|
+
userId
|
|
691
|
+
]);
|
|
692
|
+
}
|
|
693
|
+
|
|
100
694
|
// ../../src/core/auth/basicAuthGuard.ts
|
|
101
695
|
class BasicAuthGuard {
|
|
102
696
|
container;
|
|
@@ -112,24 +706,51 @@ class BasicAuthGuard {
|
|
|
112
706
|
if (!directory) {
|
|
113
707
|
return null;
|
|
114
708
|
}
|
|
115
|
-
|
|
116
|
-
|
|
709
|
+
const record = typeof directory.findByEmail === "function" ? await directory.findByEmail(credentials.username) : null;
|
|
710
|
+
if (record?.password) {
|
|
711
|
+
if (!await verifyPassword(credentials.password, record.password)) {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
const mfa = completePasswordLogin(record, {
|
|
715
|
+
mfaCode: request.headers.get("x-mfa-code")
|
|
716
|
+
});
|
|
717
|
+
if (!mfa.ok) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
if (mfa.consumedRecoveryHash) {
|
|
721
|
+
await persistConsumedRecoveryHash(getBoundDatabaseConnection() ?? getDefaultDatabasePool(), record.id, record.mfa_recovery_codes, mfa.consumedRecoveryHash);
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
id: record.id,
|
|
725
|
+
role: record.role,
|
|
726
|
+
emailVerifiedAt: record.email_verified_at ?? null
|
|
727
|
+
};
|
|
117
728
|
}
|
|
118
|
-
if (typeof directory.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
729
|
+
if (typeof directory.verifyCredentials === "function") {
|
|
730
|
+
const user = await directory.verifyCredentials(credentials.username, credentials.password);
|
|
731
|
+
if (!user) {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
let record = typeof directory.findByEmail === "function" ? await directory.findByEmail(credentials.username) : null;
|
|
735
|
+
if (!record) {
|
|
736
|
+
try {
|
|
737
|
+
record = await directory.findByIdOrThrow(Number(user.id));
|
|
738
|
+
} catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const mfa = completePasswordLogin(record, {
|
|
743
|
+
mfaCode: request.headers.get("x-mfa-code")
|
|
744
|
+
});
|
|
745
|
+
if (!mfa.ok) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
if (mfa.consumedRecoveryHash) {
|
|
749
|
+
await persistConsumedRecoveryHash(getBoundDatabaseConnection() ?? getDefaultDatabasePool(), record.id, record.mfa_recovery_codes, mfa.consumedRecoveryHash);
|
|
750
|
+
}
|
|
751
|
+
return user;
|
|
127
752
|
}
|
|
128
|
-
return
|
|
129
|
-
id: record.id,
|
|
130
|
-
role: record.role,
|
|
131
|
-
emailVerifiedAt: record.email_verified_at ?? null
|
|
132
|
-
};
|
|
753
|
+
return null;
|
|
133
754
|
}
|
|
134
755
|
}
|
|
135
756
|
export {
|
|
@@ -4,7 +4,7 @@ function isEmailVerificationRequired() {
|
|
|
4
4
|
return (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true";
|
|
5
5
|
}
|
|
6
6
|
function hasVerifiedEmail(user) {
|
|
7
|
-
return user.emailVerifiedAt
|
|
7
|
+
return user.emailVerifiedAt != null;
|
|
8
8
|
}
|
|
9
9
|
export {
|
|
10
10
|
hasVerifiedEmail,
|