@getstrata/bootstrap 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 +4 -0
- package/dist/bootstrap/secretsGuard.d.ts +13 -1
- package/dist/bootstrap/web/session.d.ts +1 -0
- package/dist/entries/buildModuleRoutes.js +34 -38
- package/dist/entries/buildWebModuleRoutes.js +34 -38
- package/dist/entries/context.js +3 -3
- package/dist/entries/createSpaRoutes.js +10 -3
- package/dist/entries/createWebRoutes.js +43 -42
- package/dist/entries/dependencies.js +3 -3
- package/dist/entries/health.js +3 -2
- package/dist/entries/httpKernel.js +28 -37
- package/dist/entries/metricsRoutes.js +2 -1
- package/dist/entries/providers.js +3 -3
- package/dist/entries/schedule.js +2 -2
- package/dist/entries/secretsGuard.js +100 -5
- package/dist/entries/web/routing.js +28 -37
- package/dist/entries/web/server.js +9 -3
- package/dist/entries/web/session.js +68 -18
- package/dist/index.js +205 -79
- package/package.json +3 -3
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// ../../src/bootstrap/metricsRoutes.ts
|
|
3
3
|
import { timingSafeEqual } from "crypto";
|
|
4
4
|
import { prometheusRegistry } from "@getstrata/core/metrics/prometheus";
|
|
5
|
+
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
5
6
|
function tokensMatch(left, right) {
|
|
6
7
|
const leftBuffer = Buffer.from(left);
|
|
7
8
|
const rightBuffer = Buffer.from(right);
|
|
@@ -17,7 +18,7 @@ function authorizeMetrics(request) {
|
|
|
17
18
|
if (expected) {
|
|
18
19
|
return presented.length > 0 && tokensMatch(presented, expected);
|
|
19
20
|
}
|
|
20
|
-
if ((
|
|
21
|
+
if (isProductionEnv()) {
|
|
21
22
|
return false;
|
|
22
23
|
}
|
|
23
24
|
return true;
|
|
@@ -14,7 +14,7 @@ import { SessionGuard } from "@getstrata/core/auth/sessionGuard";
|
|
|
14
14
|
import { envFlagEnabled } from "@getstrata/core/runtime/appEnv";
|
|
15
15
|
var authConfig = {
|
|
16
16
|
allowDevHeaders: envFlagEnabled(process.env.AUTH_DEV_HEADERS),
|
|
17
|
-
tokenDefaultAbilities: [
|
|
17
|
+
tokenDefaultAbilities: []
|
|
18
18
|
};
|
|
19
19
|
|
|
20
20
|
// ../../src/bootstrap/config.ts
|
|
@@ -50,7 +50,7 @@ var authProvider = {
|
|
|
50
50
|
config.set("auth.allowDevHeaders", authConfig.allowDevHeaders);
|
|
51
51
|
const apiGuard = new DatabaseTokenGuard(container);
|
|
52
52
|
const sessionGuard = new SessionGuard(container);
|
|
53
|
-
const jwtGuard = new JwtGuard;
|
|
53
|
+
const jwtGuard = new JwtGuard(container);
|
|
54
54
|
const basicGuard = new BasicAuthGuard(container);
|
|
55
55
|
const guards = [apiGuard, jwtGuard, basicGuard, sessionGuard];
|
|
56
56
|
if (authConfig.allowDevHeaders) {
|
|
@@ -95,7 +95,7 @@ import { validateEnv } from "@getstrata/core/config/envSchema";
|
|
|
95
95
|
var appConfig = {
|
|
96
96
|
name: process.env.APP_NAME?.trim() || "Strata",
|
|
97
97
|
env: process.env.APP_ENV ?? "local",
|
|
98
|
-
debug: (process.env.APP_DEBUG ?? "
|
|
98
|
+
debug: (process.env.APP_DEBUG ?? "false") === "true",
|
|
99
99
|
url: process.env.APP_URL ?? "http://localhost:3000",
|
|
100
100
|
apiPrefix: process.env.API_PREFIX ?? "/api/v1"
|
|
101
101
|
};
|
package/dist/entries/schedule.js
CHANGED
|
@@ -14,8 +14,8 @@ function readFeatureFlags() {
|
|
|
14
14
|
samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
|
|
15
15
|
scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
|
|
16
16
|
billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
|
|
17
|
-
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "
|
|
18
|
-
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "
|
|
17
|
+
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "false") === "true",
|
|
18
|
+
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "false") === "true",
|
|
19
19
|
emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
|
|
20
20
|
mfa: (process.env.FEATURE_MFA ?? "false") === "true",
|
|
21
21
|
registration: (process.env.FEATURE_REGISTRATION ?? "true") !== "false"
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/bootstrap/secretsGuard.ts
|
|
3
|
+
import { getDefaultDatabasePool } from "@getstrata/core/database/defaultConnection";
|
|
3
4
|
import { envFlagEnabled, isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4
5
|
import { isViewsMode, parseFrontendMode } from "@getstrata/core/runtime/frontendMode";
|
|
6
|
+
import {
|
|
7
|
+
assertPostgresRoleCannotBypassRls,
|
|
8
|
+
inspectCurrentPostgresRole,
|
|
9
|
+
isPostgresUrl,
|
|
10
|
+
postgresUrlUsername
|
|
11
|
+
} from "@getstrata/core/tenant/enableTenantRls";
|
|
12
|
+
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
5
13
|
var PUBLISHED_TEST_ADMIN_API_TOKEN = "strata-admin-test-token";
|
|
6
14
|
var PUBLISHED_TEST_MEMBER_API_TOKEN = "strata-member-test-token";
|
|
7
15
|
var PUBLISHED_TEST_SCIM_BEARER_TOKEN = "strata-scim-test-token";
|
|
@@ -22,8 +30,11 @@ var SECRETS_TO_ROTATE = [
|
|
|
22
30
|
"KMS_ENCRYPTION_KEY",
|
|
23
31
|
"STRIPE_WEBHOOK_SECRET",
|
|
24
32
|
"ADMIN_API_TOKEN",
|
|
25
|
-
"MEMBER_API_TOKEN"
|
|
33
|
+
"MEMBER_API_TOKEN",
|
|
34
|
+
"DATABASE_URL",
|
|
35
|
+
"APP_DATABASE_URL"
|
|
26
36
|
];
|
|
37
|
+
var RLS_BYPASS_DATABASE_USERS = new Set(["postgres", "root"]);
|
|
27
38
|
function assertNoPlaceholderSecrets(env) {
|
|
28
39
|
const unrotated = SECRETS_TO_ROTATE.filter((name) => PLACEHOLDER_SECRET_PATTERN.test(env[name]?.trim() ?? ""));
|
|
29
40
|
if (unrotated.length > 0) {
|
|
@@ -34,7 +45,11 @@ function isTokenAuthEnabled(env) {
|
|
|
34
45
|
return Boolean(env.ADMIN_API_TOKEN?.trim()) || Boolean(env.MEMBER_API_TOKEN?.trim()) || envFlagEnabled(env.FEATURE_API_TOKENS);
|
|
35
46
|
}
|
|
36
47
|
function isOAuthEnabled(env) {
|
|
37
|
-
return envFlagEnabled(env.FEATURE_OAUTH) || envFlagEnabled(env.FEATURE_SAML) || Boolean(env.GITHUB_CLIENT_ID?.trim()) || Boolean(env.OIDC_ISSUER?.trim()) || Boolean(env.SAML_LOGIN_URL?.trim());
|
|
48
|
+
return envFlagEnabled(env.FEATURE_OAUTH) || envFlagEnabled(env.FEATURE_SAML) || Boolean(env.GITHUB_CLIENT_ID?.trim()) || Boolean(env.OIDC_ISSUER?.trim()) || Boolean(env.SAML_IDP_SSO_URL?.trim()) || Boolean(env.SAML_LOGIN_URL?.trim());
|
|
49
|
+
}
|
|
50
|
+
function isHeaderOnlyAuth(env) {
|
|
51
|
+
const frontend = env.FRONTEND_MODE?.trim() ?? "";
|
|
52
|
+
return env.AUTH_MODE === "headers" || frontend === "api" && env.AUTH_DEV_HEADERS === "true";
|
|
38
53
|
}
|
|
39
54
|
function isCorsConfigured(env) {
|
|
40
55
|
return env.CORS_ALLOWED_ORIGINS !== undefined;
|
|
@@ -77,8 +92,27 @@ function assertFeatureProductionSecrets(env) {
|
|
|
77
92
|
throw new Error("Production startup blocked: rotate SCIM_BEARER_TOKEN away from published test defaults.");
|
|
78
93
|
}
|
|
79
94
|
}
|
|
80
|
-
if (envFlagEnabled(env.FEATURE_FIELD_ENCRYPTION) && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
81
|
-
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption is enabled.");
|
|
95
|
+
if ((envFlagEnabled(env.FEATURE_FIELD_ENCRYPTION) || envFlagEnabled(env.FEATURE_MFA)) && !env.KMS_ENCRYPTION_KEY?.trim()) {
|
|
96
|
+
throw new Error("Production startup blocked: set KMS_ENCRYPTION_KEY when field encryption or MFA is enabled.");
|
|
97
|
+
}
|
|
98
|
+
if (envFlagEnabled(env.FEATURE_SAML)) {
|
|
99
|
+
const required = [
|
|
100
|
+
"SAML_IDP_CERT",
|
|
101
|
+
"SAML_IDP_SSO_URL",
|
|
102
|
+
"SAML_SP_ENTITY_ID",
|
|
103
|
+
"SAML_ACS_URL",
|
|
104
|
+
"SAML_IDP_ISSUER"
|
|
105
|
+
];
|
|
106
|
+
const missing = required.filter((name) => !env[name]?.trim());
|
|
107
|
+
if (missing.length > 0) {
|
|
108
|
+
throw new Error(`Production startup blocked: set ${missing.join(", ")} when FEATURE_SAML=true.`);
|
|
109
|
+
}
|
|
110
|
+
if (env.SAML_WANT_RESPONSE_SIGNED === "false") {
|
|
111
|
+
throw new Error("Production startup blocked: signed SAML responses are required (do not set SAML_WANT_RESPONSE_SIGNED=false).");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (envFlagEnabled(env.UPLOAD_ALLOW_UNKNOWN_MIME)) {
|
|
115
|
+
throw new Error("Production startup blocked: set UPLOAD_ALLOW_UNKNOWN_MIME=false (unknown MIME types are not allowed).");
|
|
82
116
|
}
|
|
83
117
|
if (envFlagEnabled(env.FEATURE_BILLING) && !env.STRIPE_WEBHOOK_SECRET?.trim()) {
|
|
84
118
|
throw new Error("Production startup blocked: set STRIPE_WEBHOOK_SECRET when billing webhooks are enabled.");
|
|
@@ -100,6 +134,61 @@ function assertFeatureProductionSecrets(env) {
|
|
|
100
134
|
}
|
|
101
135
|
}
|
|
102
136
|
var LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
|
|
137
|
+
var livePostgresRoleInspector = null;
|
|
138
|
+
function setLivePostgresRoleInspectorForTests(inspect) {
|
|
139
|
+
livePostgresRoleInspector = inspect;
|
|
140
|
+
}
|
|
141
|
+
function rlsRuntimeDatabaseUrl(env) {
|
|
142
|
+
const app = env.APP_DATABASE_URL?.trim();
|
|
143
|
+
if (app) {
|
|
144
|
+
return { name: "APP_DATABASE_URL", raw: app };
|
|
145
|
+
}
|
|
146
|
+
const database = env.DATABASE_URL?.trim();
|
|
147
|
+
if (database) {
|
|
148
|
+
return { name: "DATABASE_URL", raw: database };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function assertRlsUsesAppDatabaseRole(env) {
|
|
153
|
+
if (!isRlsTenancy(env)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const runtime = rlsRuntimeDatabaseUrl(env);
|
|
157
|
+
if (!runtime) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const user = postgresUrlUsername(runtime.raw);
|
|
161
|
+
if (user === null) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (user === "") {
|
|
165
|
+
throw new Error(`TENANCY_DRIVER=rls startup blocked: ${runtime.name} must include a NOBYPASSRLS role username.`);
|
|
166
|
+
}
|
|
167
|
+
if (RLS_BYPASS_DATABASE_USERS.has(user.toLowerCase())) {
|
|
168
|
+
throw new Error(`TENANCY_DRIVER=rls startup blocked: ${runtime.name} for TENANCY_DRIVER=rls must use a NOBYPASSRLS role, not ${user}. FORCE RLS does not apply to PostgreSQL superusers.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function defaultInspectLivePostgresRole() {
|
|
172
|
+
return await inspectCurrentPostgresRole(getDefaultDatabasePool());
|
|
173
|
+
}
|
|
174
|
+
async function assertRlsLiveDatabaseRole(env = process.env, inspect) {
|
|
175
|
+
if (!isRlsTenancy(env)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const runtime = rlsRuntimeDatabaseUrl(env);
|
|
179
|
+
if (!runtime || !isPostgresUrl(runtime.raw)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const source = runtime.name;
|
|
183
|
+
assertRlsUsesAppDatabaseRole(env);
|
|
184
|
+
let role;
|
|
185
|
+
try {
|
|
186
|
+
role = await (inspect ?? livePostgresRoleInspector ?? defaultInspectLivePostgresRole)();
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(`TENANCY_DRIVER=rls startup blocked: could not inspect the live Postgres role for ${source}.`);
|
|
189
|
+
}
|
|
190
|
+
assertPostgresRoleCannotBypassRls(role, source);
|
|
191
|
+
}
|
|
103
192
|
function assertPublicAppUrl(env) {
|
|
104
193
|
const raw = env.APP_URL?.trim() ?? "";
|
|
105
194
|
let url = null;
|
|
@@ -122,6 +211,9 @@ function assertProductionSecrets(env = process.env) {
|
|
|
122
211
|
assertNoPlaceholderSecrets(env);
|
|
123
212
|
assertAuthDevHeadersDisabled(env);
|
|
124
213
|
assertPublicAppUrl(env);
|
|
214
|
+
if (isHeaderOnlyAuth(env)) {
|
|
215
|
+
throw new Error("Production startup blocked: header-only authentication is not allowed. Configure cookie, token, or JWT auth.");
|
|
216
|
+
}
|
|
125
217
|
if (isTokenAuthEnabled(env)) {
|
|
126
218
|
assertTokenAuthProductionSecrets(env);
|
|
127
219
|
}
|
|
@@ -129,7 +221,10 @@ function assertProductionSecrets(env = process.env) {
|
|
|
129
221
|
if (isViewsMode(parseFrontendMode(env.FRONTEND_MODE))) {
|
|
130
222
|
assertSessionSecret(env);
|
|
131
223
|
}
|
|
224
|
+
assertRlsUsesAppDatabaseRole(env);
|
|
132
225
|
}
|
|
133
226
|
export {
|
|
134
|
-
assertProductionSecrets
|
|
227
|
+
assertProductionSecrets,
|
|
228
|
+
assertRlsLiveDatabaseRole,
|
|
229
|
+
setLivePostgresRoleInspectorForTests
|
|
135
230
|
};
|
|
@@ -144,8 +144,9 @@ class HttpKernel {
|
|
|
144
144
|
case "web":
|
|
145
145
|
return isViewsEnabled() ? [createFlashMiddleware(), createCsrfMiddleware()] : [];
|
|
146
146
|
case "api": {
|
|
147
|
+
const csrf = createCsrfMiddleware();
|
|
147
148
|
if (!this.dependencies.container.has(CORE_CONFIG_TOKEN)) {
|
|
148
|
-
return [];
|
|
149
|
+
return [csrf];
|
|
149
150
|
}
|
|
150
151
|
const config = this.dependencies.container.resolve(CORE_CONFIG_TOKEN);
|
|
151
152
|
const redisUrl = config.get(REDIS_URL_CONFIG_KEY)?.trim() ?? "";
|
|
@@ -155,7 +156,8 @@ class HttpKernel {
|
|
|
155
156
|
createMemoryThrottleMiddleware({
|
|
156
157
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
157
158
|
decaySeconds: 60
|
|
158
|
-
})
|
|
159
|
+
}),
|
|
160
|
+
csrf
|
|
159
161
|
];
|
|
160
162
|
}
|
|
161
163
|
const maxAttempts = Number(process.env.RATE_LIMIT_PER_MINUTE ?? "120");
|
|
@@ -164,7 +166,8 @@ class HttpKernel {
|
|
|
164
166
|
redisUrl,
|
|
165
167
|
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 120,
|
|
166
168
|
decaySeconds: 60
|
|
167
|
-
})
|
|
169
|
+
}),
|
|
170
|
+
csrf
|
|
168
171
|
];
|
|
169
172
|
}
|
|
170
173
|
default:
|
|
@@ -174,13 +177,14 @@ class HttpKernel {
|
|
|
174
177
|
wrap(groups, handler) {
|
|
175
178
|
const names = Array.isArray(groups) ? groups : [groups];
|
|
176
179
|
const middleware = names.flatMap((name) => this.group(name));
|
|
177
|
-
|
|
178
|
-
|
|
180
|
+
const wrapped = middleware.length === 0 ? handler : withMiddleware(...middleware)(handler);
|
|
181
|
+
if (names.includes("api")) {
|
|
182
|
+
return withJsonErrorHandling(wrapped);
|
|
179
183
|
}
|
|
180
|
-
return
|
|
184
|
+
return wrapped;
|
|
181
185
|
}
|
|
182
186
|
wrapApi(handler) {
|
|
183
|
-
return
|
|
187
|
+
return this.wrap(["api", "authenticated"], handler);
|
|
184
188
|
}
|
|
185
189
|
wrapWeb(handler) {
|
|
186
190
|
return withErrorHandling(this.wrap("web", handler));
|
|
@@ -289,41 +293,28 @@ class HttpKernel {
|
|
|
289
293
|
return [createRequireVerifiedMiddleware(auth)];
|
|
290
294
|
}
|
|
291
295
|
wrapThrottle(scope, rateLimit, handler) {
|
|
292
|
-
const middleware = [];
|
|
293
296
|
const memoryKeyPrefix = scope === "login" ? "login-throttle:" : "register-throttle:";
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
decaySeconds: rateLimit.decaySeconds,
|
|
306
|
-
keyPrefix: memoryKeyPrefix
|
|
307
|
-
});
|
|
308
|
-
middleware.push(throttle);
|
|
309
|
-
} else {
|
|
310
|
-
middleware.push(createMemoryThrottleMiddleware({
|
|
311
|
-
maxAttempts: rateLimit.maxAttempts,
|
|
312
|
-
decaySeconds: rateLimit.decaySeconds,
|
|
313
|
-
keyPrefix: memoryKeyPrefix
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
} else {
|
|
317
|
-
middleware.push(createMemoryThrottleMiddleware({
|
|
297
|
+
const redisUrl = this.dependencies.container.has(CORE_CONFIG_TOKEN) ? this.dependencies.container.resolve(CORE_CONFIG_TOKEN).get(REDIS_URL_CONFIG_KEY)?.trim() ?? "" : "";
|
|
298
|
+
if (scope === "login") {
|
|
299
|
+
return withMiddleware(createLoginThrottleMiddleware({
|
|
300
|
+
...redisUrl ? { redisUrl } : {},
|
|
301
|
+
maxAttempts: rateLimit.maxAttempts,
|
|
302
|
+
decaySeconds: rateLimit.decaySeconds
|
|
303
|
+
}))(handler);
|
|
304
|
+
}
|
|
305
|
+
if (redisUrl) {
|
|
306
|
+
return withMiddleware(createThrottleMiddleware({
|
|
307
|
+
redisUrl,
|
|
318
308
|
maxAttempts: rateLimit.maxAttempts,
|
|
319
309
|
decaySeconds: rateLimit.decaySeconds,
|
|
320
310
|
keyPrefix: memoryKeyPrefix
|
|
321
|
-
}));
|
|
322
|
-
}
|
|
323
|
-
if (middleware.length === 0) {
|
|
324
|
-
return handler;
|
|
311
|
+
}))(handler);
|
|
325
312
|
}
|
|
326
|
-
return withMiddleware(
|
|
313
|
+
return withMiddleware(createMemoryThrottleMiddleware({
|
|
314
|
+
maxAttempts: rateLimit.maxAttempts,
|
|
315
|
+
decaySeconds: rateLimit.decaySeconds,
|
|
316
|
+
keyPrefix: memoryKeyPrefix
|
|
317
|
+
}))(handler);
|
|
327
318
|
}
|
|
328
319
|
}
|
|
329
320
|
function createHttpKernel(dependencies) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../../src/bootstrap/web/server.ts
|
|
3
3
|
import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
4
|
+
import { assertUrlPathUnderRoot } from "@getstrata/core/security/safePath";
|
|
4
5
|
import { notFoundHtmlResponse } from "@getstrata/core/view";
|
|
5
6
|
function socketAddress(server, request) {
|
|
6
7
|
const address = server?.requestIP(request)?.address;
|
|
@@ -60,9 +61,14 @@ function createWebServer(options) {
|
|
|
60
61
|
await options.onRequest?.(request);
|
|
61
62
|
const url = new URL(request.url);
|
|
62
63
|
if (url.pathname.startsWith("/assets/")) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
try {
|
|
65
|
+
const filePath = assertUrlPathUnderRoot(publicDir, url.pathname);
|
|
66
|
+
const file = Bun.file(filePath);
|
|
67
|
+
if (await file.exists()) {
|
|
68
|
+
return new Response(file);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return await missingHtmlResponse();
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
if (options.handle) {
|
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
// ../../src/bootstrap/web/session.ts
|
|
3
3
|
import { createHmac, randomBytes } from "crypto";
|
|
4
4
|
import { AuthManager } from "@getstrata/core/auth/guard";
|
|
5
|
+
import { isSessionInvalidated } from "@getstrata/core/auth/sessionCookie";
|
|
5
6
|
import { getBoundDatabaseConnection } from "@getstrata/core/database/boundConnection";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getActiveDatabaseConnection,
|
|
9
|
+
hasActiveDatabaseConnection
|
|
10
|
+
} from "@getstrata/core/database/connectionContext";
|
|
11
|
+
import {
|
|
12
|
+
getDefaultDatabasePool,
|
|
13
|
+
getDefaultDatabaseQuery
|
|
14
|
+
} from "@getstrata/core/database/defaultConnection";
|
|
7
15
|
import { currentSqlDialect, sqlTimestamp } from "@getstrata/core/database/dialect";
|
|
8
16
|
import { readRequestCookie } from "@getstrata/core/http/cookies";
|
|
9
17
|
import { currentRequestMeta } from "@getstrata/core/http/requestMetaContext";
|
|
10
18
|
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
11
19
|
import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
|
|
20
|
+
import { runWithMigrationBypassForIdentifier } from "@getstrata/core/tenant/databaseTenantContext";
|
|
12
21
|
function sqlPlaceholder(index) {
|
|
13
22
|
return currentSqlDialect().placeholder(index);
|
|
14
23
|
}
|
|
@@ -19,10 +28,18 @@ function isSqlClient(value) {
|
|
|
19
28
|
return typeof value.unsafe === "function";
|
|
20
29
|
}
|
|
21
30
|
function resolveSql(source) {
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
const client = isSqlClient(source) ? source : source();
|
|
32
|
+
if (!hasActiveDatabaseConnection()) {
|
|
33
|
+
return client;
|
|
24
34
|
}
|
|
25
|
-
|
|
35
|
+
try {
|
|
36
|
+
if (client === getDefaultDatabasePool() || client === getDefaultDatabaseQuery()) {
|
|
37
|
+
return getActiveDatabaseConnection(client);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
return client;
|
|
41
|
+
}
|
|
42
|
+
return client;
|
|
26
43
|
}
|
|
27
44
|
function defaultSessionSql() {
|
|
28
45
|
const bound = getBoundDatabaseConnection();
|
|
@@ -59,14 +76,32 @@ function mapSessionUserRow(row) {
|
|
|
59
76
|
};
|
|
60
77
|
}
|
|
61
78
|
async function defaultLoadSessionUser(sql, sessionId) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
WHERE
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
const sessionRows = await runWithMigrationBypassForIdentifier(sessionId, async () => {
|
|
80
|
+
return await sql.unsafe(`SELECT user_id, expires_at, created_at AS session_created_at
|
|
81
|
+
FROM sessions
|
|
82
|
+
WHERE id = ${sqlPlaceholder(1)} AND expires_at > ${sqlNow()}`, [sessionId]);
|
|
83
|
+
});
|
|
84
|
+
const session = sessionRows[0];
|
|
85
|
+
if (!session)
|
|
86
|
+
return null;
|
|
87
|
+
const userId = Number(session.user_id);
|
|
88
|
+
if (!Number.isInteger(userId) || userId <= 0) {
|
|
68
89
|
return null;
|
|
69
|
-
|
|
90
|
+
}
|
|
91
|
+
return await runWithMigrationBypassForIdentifier(userId, async () => {
|
|
92
|
+
const rows = await sql.unsafe(`SELECT * FROM users WHERE id = ${sqlPlaceholder(1)}`, [
|
|
93
|
+
userId
|
|
94
|
+
]);
|
|
95
|
+
const row = rows[0];
|
|
96
|
+
if (!row)
|
|
97
|
+
return null;
|
|
98
|
+
const createdSource = session.session_created_at;
|
|
99
|
+
const createdAt = createdSource instanceof Date ? createdSource.getTime() : createdSource ? Date.parse(String(createdSource)) : Number.NaN;
|
|
100
|
+
if (!Number.isFinite(createdAt) || isSessionInvalidated(createdAt, row.session_valid_after)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return mapSessionUserRow({ ...row, user_id: userId, session_created_at: createdSource });
|
|
104
|
+
});
|
|
70
105
|
}
|
|
71
106
|
function redirectWithCookie(location, setCookie, status) {
|
|
72
107
|
return new Response(null, {
|
|
@@ -121,25 +156,40 @@ class CookieSessionStore {
|
|
|
121
156
|
async create(user, meta = {}) {
|
|
122
157
|
const id = randomBytes(32).toString("hex");
|
|
123
158
|
const expires = new Date(Date.now() + this.maxAgeSeconds * 1000);
|
|
124
|
-
await
|
|
125
|
-
|
|
159
|
+
await runWithMigrationBypassForIdentifier(user.id, async () => {
|
|
160
|
+
await this.sql().unsafe(`INSERT INTO sessions (id, user_id, expires_at, user_agent, ip_address, last_active_at, created_at)
|
|
161
|
+
VALUES (${sqlPlaceholder(1)}, ${sqlPlaceholder(2)}, ${sqlPlaceholder(3)}, ${sqlPlaceholder(4)}, ${sqlPlaceholder(5)}, ${sqlNow()}, ${sqlNow()})`, [id, user.id, sqlTimestamp(expires), meta.userAgent ?? null, meta.ipAddress ?? null]);
|
|
162
|
+
});
|
|
126
163
|
return id;
|
|
127
164
|
}
|
|
128
165
|
async destroy(sessionId) {
|
|
129
|
-
await
|
|
166
|
+
await runWithMigrationBypassForIdentifier(sessionId, async () => {
|
|
167
|
+
await this.sql().unsafe(`DELETE FROM sessions WHERE id = ${sqlPlaceholder(1)}`, [sessionId]);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async destroyAllSessions(userId) {
|
|
171
|
+
await runWithMigrationBypassForIdentifier(userId, async () => {
|
|
172
|
+
await this.sql().unsafe(`DELETE FROM sessions WHERE user_id = ${sqlPlaceholder(1)}`, [
|
|
173
|
+
userId
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
130
176
|
}
|
|
131
177
|
async destroyOtherSessions(userId, keepSessionId) {
|
|
132
|
-
await
|
|
178
|
+
await runWithMigrationBypassForIdentifier(userId, async () => {
|
|
179
|
+
await this.sql().unsafe(`DELETE FROM sessions WHERE user_id = ${sqlPlaceholder(1)} AND id <> ${sqlPlaceholder(2)}`, [userId, keepSessionId]);
|
|
180
|
+
});
|
|
133
181
|
}
|
|
134
182
|
async listForUser(userId) {
|
|
135
183
|
const dialect = currentSqlDialect();
|
|
136
|
-
return this.sql().unsafe(`SELECT id, user_id, user_agent, ip_address, last_active_at, expires_at
|
|
184
|
+
return await runWithMigrationBypassForIdentifier(userId, () => this.sql().unsafe(`SELECT id, user_id, user_agent, ip_address, last_active_at, expires_at
|
|
137
185
|
FROM sessions
|
|
138
186
|
WHERE user_id = ${dialect.placeholder(1)} AND expires_at > ${dialect.nowExpression()}
|
|
139
|
-
ORDER BY last_active_at DESC${dialect.nullsLastSuffix()}, expires_at DESC`, [userId]);
|
|
187
|
+
ORDER BY last_active_at DESC${dialect.nullsLastSuffix()}, expires_at DESC`, [userId]));
|
|
140
188
|
}
|
|
141
189
|
async touch(sessionId) {
|
|
142
|
-
await
|
|
190
|
+
await runWithMigrationBypassForIdentifier(sessionId, async () => {
|
|
191
|
+
await this.sql().unsafe(`UPDATE sessions SET last_active_at = ${sqlNow()} WHERE id = ${sqlPlaceholder(1)}`, [sessionId]);
|
|
192
|
+
});
|
|
143
193
|
}
|
|
144
194
|
async read(request) {
|
|
145
195
|
const sessionId = this.sessionIdFromRequest(request);
|