@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
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// @bun
|
|
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) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
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;
|
|
111
|
+
}
|
|
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";
|
|
123
|
+
}
|
|
124
|
+
|
|
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.");
|
|
319
|
+
}
|
|
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
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function withDatabaseErrorHandling(operation) {
|
|
363
|
+
try {
|
|
364
|
+
return await operation();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
throw mapDatabaseError(error);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
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
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
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()]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
var sqlReplayStore = new SqlSamlAssertionReplayStore;
|
|
424
|
+
var replayStoreOverride = null;
|
|
425
|
+
function samlAssertionReplayStore() {
|
|
426
|
+
return replayStoreOverride ?? sqlReplayStore;
|
|
427
|
+
}
|
|
428
|
+
function setSamlAssertionReplayStoreForTests(store) {
|
|
429
|
+
replayStoreOverride = store;
|
|
430
|
+
}
|
|
431
|
+
function resetSamlReplayCacheForTests() {
|
|
432
|
+
if (replayStoreOverride instanceof InMemorySamlAssertionReplayStore) {
|
|
433
|
+
replayStoreOverride.clear();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
replayStoreOverride = null;
|
|
437
|
+
}
|
|
438
|
+
async function consumeSamlAssertion(assertionId) {
|
|
439
|
+
await samlAssertionReplayStore().consume(assertionId);
|
|
440
|
+
}
|
|
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() : "";
|
|
450
|
+
}
|
|
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
|
+
};
|
|
476
|
+
}
|
|
477
|
+
var nodeSamlLoader = null;
|
|
478
|
+
async function defaultLoadNodeSaml() {
|
|
479
|
+
return await import("@node-saml/node-saml");
|
|
480
|
+
}
|
|
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
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function setNodeSamlLoaderForTests(loader) {
|
|
489
|
+
nodeSamlLoader = loader;
|
|
490
|
+
}
|
|
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;
|
|
521
|
+
}
|
|
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);
|
|
560
|
+
}
|
|
561
|
+
export {
|
|
562
|
+
InMemorySamlAssertionReplayStore,
|
|
563
|
+
REPLAY_RETENTION_MS,
|
|
564
|
+
SamlServiceProvider,
|
|
565
|
+
consumeSamlAssertion,
|
|
566
|
+
createSamlServiceProvider,
|
|
567
|
+
readSamlEnvOptions,
|
|
568
|
+
resetSamlReplayCacheForTests,
|
|
569
|
+
setNodeSamlLoaderForTests,
|
|
570
|
+
setSamlAssertionReplayStoreForTests
|
|
571
|
+
};
|
|
@@ -61,14 +61,21 @@ function parseScimTenantTokens(raw) {
|
|
|
61
61
|
return tokens;
|
|
62
62
|
}
|
|
63
63
|
function resolveScimTenantFromToken(token) {
|
|
64
|
+
const presented = token.trim();
|
|
65
|
+
if (presented.length === 0) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
64
68
|
const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
|
|
65
69
|
for (const [tenantId, expectedToken] of tenantTokens) {
|
|
66
|
-
if (timingSafeCompareString(
|
|
70
|
+
if (timingSafeCompareString(presented, expectedToken)) {
|
|
67
71
|
return tenantId;
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
|
-
const fallbackToken = process.env.SCIM_BEARER_TOKEN ?? "";
|
|
71
|
-
if (
|
|
74
|
+
const fallbackToken = process.env.SCIM_BEARER_TOKEN?.trim() ?? "";
|
|
75
|
+
if (fallbackToken.length === 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (timingSafeCompareString(presented, fallbackToken)) {
|
|
72
79
|
return 1;
|
|
73
80
|
}
|
|
74
81
|
return null;
|
|
@@ -103,7 +103,7 @@ function sessionRememberTtlSeconds() {
|
|
|
103
103
|
return parsePositiveSeconds(process.env.SESSION_REMEMBER_TTL_SECONDS, SESSION_REMEMBER_TTL_SECONDS);
|
|
104
104
|
}
|
|
105
105
|
function resolveSessionSecret() {
|
|
106
|
-
return requireConfiguredSecret(["SESSION_SECRET"
|
|
106
|
+
return requireConfiguredSecret(["SESSION_SECRET"], "session-secret");
|
|
107
107
|
}
|
|
108
108
|
function signSession(userId, issuedAt, ttlSeconds) {
|
|
109
109
|
const payload = ttlSeconds === undefined ? `${userId}.${issuedAt}` : `${userId}.${issuedAt}.${ttlSeconds}`;
|
|
@@ -170,7 +170,7 @@ function sessionRememberTtlSeconds() {
|
|
|
170
170
|
return parsePositiveSeconds(process.env.SESSION_REMEMBER_TTL_SECONDS, SESSION_REMEMBER_TTL_SECONDS);
|
|
171
171
|
}
|
|
172
172
|
function resolveSessionSecret() {
|
|
173
|
-
return requireConfiguredSecret(["SESSION_SECRET"
|
|
173
|
+
return requireConfiguredSecret(["SESSION_SECRET"], "session-secret");
|
|
174
174
|
}
|
|
175
175
|
function signSession(userId, issuedAt, ttlSeconds) {
|
|
176
176
|
const payload = ttlSeconds === undefined ? `${userId}.${issuedAt}` : `${userId}.${issuedAt}.${ttlSeconds}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/core/auth/tokenHash.ts
|
|
3
|
-
import {
|
|
3
|
+
import { createHmac } from "crypto";
|
|
4
4
|
|
|
5
5
|
// ../../src/core/runtime/appEnv.ts
|
|
6
6
|
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
@@ -90,11 +90,7 @@ function resolveTokenPepper() {
|
|
|
90
90
|
return requireConfiguredSecret(["TOKEN_HASH_PEPPER"], "token-pepper");
|
|
91
91
|
}
|
|
92
92
|
function hashApiToken(token) {
|
|
93
|
-
|
|
94
|
-
if (pepper && pepper !== appDevSecret("token-pepper")) {
|
|
95
|
-
return createHmac("sha256", pepper).update(token).digest("hex");
|
|
96
|
-
}
|
|
97
|
-
return createHash("sha256").update(token).digest("hex");
|
|
93
|
+
return createHmac("sha256", resolveTokenPepper()).update(token).digest("hex");
|
|
98
94
|
}
|
|
99
95
|
export {
|
|
100
96
|
hashApiToken,
|
|
@@ -26,7 +26,7 @@ function isFieldEncryptionEnabled() {
|
|
|
26
26
|
if (featureFlag === "true") {
|
|
27
27
|
return true;
|
|
28
28
|
}
|
|
29
|
-
return (
|
|
29
|
+
return Boolean(resolveEncryptionKey());
|
|
30
30
|
}
|
|
31
31
|
function encryptField(plaintext, key) {
|
|
32
32
|
const iv = randomBytes(IV_LENGTH);
|