@constructive-io/graphql-server 4.17.0 → 4.18.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/esm/middleware/api.js +76 -5
- package/esm/middleware/auth.js +23 -4
- package/esm/middleware/captcha.js +111 -0
- package/esm/middleware/graphile.js +18 -7
- package/esm/server.js +2 -0
- package/middleware/api.js +76 -5
- package/middleware/auth.js +23 -4
- package/middleware/captcha.d.ts +16 -0
- package/middleware/captcha.js +115 -0
- package/middleware/graphile.js +18 -7
- package/middleware/types.d.ts +2 -0
- package/package.json +18 -18
- package/server.js +2 -0
- package/types.d.ts +18 -0
package/esm/middleware/api.js
CHANGED
|
@@ -75,6 +75,34 @@ const RLS_MODULE_SQL = `
|
|
|
75
75
|
WHERE api_id = $1 AND name = 'rls_module'
|
|
76
76
|
LIMIT 1
|
|
77
77
|
`;
|
|
78
|
+
/**
|
|
79
|
+
* Discover auth settings table location via public metaschema tables.
|
|
80
|
+
* Joins sessions_module with metaschema_public.schema to resolve
|
|
81
|
+
* the schema name + table name without touching private schemas.
|
|
82
|
+
*/
|
|
83
|
+
const AUTH_SETTINGS_DISCOVERY_SQL = `
|
|
84
|
+
SELECT s.schema_name, sm.auth_settings_table AS table_name
|
|
85
|
+
FROM metaschema_modules_public.sessions_module sm
|
|
86
|
+
JOIN metaschema_public.schema s ON s.id = sm.schema_id
|
|
87
|
+
LIMIT 1
|
|
88
|
+
`;
|
|
89
|
+
/**
|
|
90
|
+
* Query auth settings from the discovered table.
|
|
91
|
+
* Schema and table name are resolved dynamically from metaschema modules.
|
|
92
|
+
*/
|
|
93
|
+
const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
|
|
94
|
+
SELECT
|
|
95
|
+
cookie_secure,
|
|
96
|
+
cookie_samesite,
|
|
97
|
+
cookie_domain,
|
|
98
|
+
cookie_httponly,
|
|
99
|
+
cookie_max_age,
|
|
100
|
+
cookie_path,
|
|
101
|
+
enable_captcha,
|
|
102
|
+
captcha_site_key
|
|
103
|
+
FROM "${schemaName}"."${tableName}"
|
|
104
|
+
LIMIT 1
|
|
105
|
+
`;
|
|
78
106
|
// =============================================================================
|
|
79
107
|
// Helpers
|
|
80
108
|
// =============================================================================
|
|
@@ -127,7 +155,21 @@ const toRlsModule = (row) => {
|
|
|
127
155
|
currentUserAgent: d.current_user_agent,
|
|
128
156
|
};
|
|
129
157
|
};
|
|
130
|
-
const
|
|
158
|
+
const toAuthSettings = (row) => {
|
|
159
|
+
if (!row)
|
|
160
|
+
return undefined;
|
|
161
|
+
return {
|
|
162
|
+
cookieSecure: row.cookie_secure,
|
|
163
|
+
cookieSamesite: row.cookie_samesite,
|
|
164
|
+
cookieDomain: row.cookie_domain,
|
|
165
|
+
cookieHttponly: row.cookie_httponly,
|
|
166
|
+
cookieMaxAge: row.cookie_max_age,
|
|
167
|
+
cookiePath: row.cookie_path,
|
|
168
|
+
enableCaptcha: row.enable_captcha,
|
|
169
|
+
captchaSiteKey: row.captcha_site_key,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
const toApiStructure = (row, opts, rlsModuleRow, authSettingsRow) => ({
|
|
131
173
|
apiId: row.api_id,
|
|
132
174
|
dbname: row.dbname || opts.pg?.database || '',
|
|
133
175
|
anonRole: row.anon_role || 'anon',
|
|
@@ -138,6 +180,7 @@ const toApiStructure = (row, opts, rlsModuleRow) => ({
|
|
|
138
180
|
domains: [],
|
|
139
181
|
databaseId: row.database_id,
|
|
140
182
|
isPublic: row.is_public,
|
|
183
|
+
authSettings: toAuthSettings(authSettingsRow ?? null),
|
|
141
184
|
});
|
|
142
185
|
const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
143
186
|
dbname: opts.pg?.database ?? '',
|
|
@@ -172,6 +215,32 @@ const queryRlsModule = async (pool, apiId) => {
|
|
|
172
215
|
const result = await pool.query(RLS_MODULE_SQL, [apiId]);
|
|
173
216
|
return result.rows[0] ?? null;
|
|
174
217
|
};
|
|
218
|
+
/**
|
|
219
|
+
* Load server-relevant auth settings from the tenant DB.
|
|
220
|
+
* Discovers the auth settings table dynamically by joining
|
|
221
|
+
* metaschema_modules_public.sessions_module with metaschema_public.schema
|
|
222
|
+
* (both public schemas). Fails gracefully if modules or table don't exist yet.
|
|
223
|
+
*/
|
|
224
|
+
const queryAuthSettings = async (opts, dbname) => {
|
|
225
|
+
try {
|
|
226
|
+
const tenantPool = getPgPool({ ...opts.pg, database: dbname });
|
|
227
|
+
// Discover the auth settings schema + table name from public metaschema tables
|
|
228
|
+
const discovery = await tenantPool.query(AUTH_SETTINGS_DISCOVERY_SQL);
|
|
229
|
+
const resolved = discovery.rows[0];
|
|
230
|
+
if (!resolved) {
|
|
231
|
+
log.debug('[auth-settings] No sessions_module row found in tenant DB');
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
// Query the discovered auth settings table
|
|
235
|
+
const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
|
|
236
|
+
return result.rows[0] ?? null;
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
// Table/module may not exist yet if the 2FA migration hasn't been applied
|
|
240
|
+
log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
175
244
|
// =============================================================================
|
|
176
245
|
// Resolution Logic
|
|
177
246
|
// =============================================================================
|
|
@@ -223,8 +292,9 @@ const resolveApiNameHeader = async (ctx) => {
|
|
|
223
292
|
return null;
|
|
224
293
|
}
|
|
225
294
|
const rlsModule = await queryRlsModule(pool, row.api_id);
|
|
226
|
-
|
|
227
|
-
|
|
295
|
+
const authSettings = await queryAuthSettings(opts, row.dbname);
|
|
296
|
+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
|
|
297
|
+
return toApiStructure(row, opts, rlsModule, authSettings);
|
|
228
298
|
};
|
|
229
299
|
const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
|
|
230
300
|
return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
|
|
@@ -239,8 +309,9 @@ const resolveDomainLookup = async (ctx) => {
|
|
|
239
309
|
return null;
|
|
240
310
|
}
|
|
241
311
|
const rlsModule = await queryRlsModule(pool, row.api_id);
|
|
242
|
-
|
|
243
|
-
|
|
312
|
+
const authSettings = await queryAuthSettings(opts, row.dbname);
|
|
313
|
+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
|
|
314
|
+
return toApiStructure(row, opts, rlsModule, authSettings);
|
|
244
315
|
};
|
|
245
316
|
const buildDevFallbackError = async (ctx, req) => {
|
|
246
317
|
if (getNodeEnv() !== 'development')
|
package/esm/middleware/auth.js
CHANGED
|
@@ -5,6 +5,19 @@ import pgQueryContext from 'pg-query-context';
|
|
|
5
5
|
import './types'; // for Request type
|
|
6
6
|
const log = new Logger('auth');
|
|
7
7
|
const isDev = () => getNodeEnv() === 'development';
|
|
8
|
+
/** Default cookie name for session tokens. */
|
|
9
|
+
const SESSION_COOKIE_NAME = 'constructive_session';
|
|
10
|
+
/**
|
|
11
|
+
* Extract a named cookie value from the raw Cookie header.
|
|
12
|
+
* Avoids pulling in cookie-parser as a dependency.
|
|
13
|
+
*/
|
|
14
|
+
const parseCookieToken = (req, cookieName) => {
|
|
15
|
+
const header = req.headers.cookie;
|
|
16
|
+
if (!header)
|
|
17
|
+
return undefined;
|
|
18
|
+
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
|
|
19
|
+
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
|
|
20
|
+
};
|
|
8
21
|
export const createAuthenticateMiddleware = (opts) => {
|
|
9
22
|
return async (req, res, next) => {
|
|
10
23
|
const api = req.api;
|
|
@@ -36,8 +49,14 @@ export const createAuthenticateMiddleware = (opts) => {
|
|
|
36
49
|
let token = {};
|
|
37
50
|
log.info(`[auth] authorization header present=${!!authorization}, ` +
|
|
38
51
|
`authType=${authType ?? 'none'}, hasToken=${!!authToken}`);
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
// Resolve the credential: prefer Bearer header, fall back to session cookie
|
|
53
|
+
const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME);
|
|
54
|
+
const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken)
|
|
55
|
+
? authToken
|
|
56
|
+
: cookieToken;
|
|
57
|
+
const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none');
|
|
58
|
+
if (effectiveToken) {
|
|
59
|
+
log.info(`[auth] Processing ${tokenSource} authentication`);
|
|
41
60
|
const context = {
|
|
42
61
|
'jwt.claims.ip_address': req.clientIp,
|
|
43
62
|
};
|
|
@@ -54,7 +73,7 @@ export const createAuthenticateMiddleware = (opts) => {
|
|
|
54
73
|
client: pool,
|
|
55
74
|
context,
|
|
56
75
|
query: authQuery,
|
|
57
|
-
variables: [
|
|
76
|
+
variables: [effectiveToken],
|
|
58
77
|
});
|
|
59
78
|
log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
|
|
60
79
|
if (result?.rowCount === 0) {
|
|
@@ -83,7 +102,7 @@ export const createAuthenticateMiddleware = (opts) => {
|
|
|
83
102
|
}
|
|
84
103
|
}
|
|
85
104
|
else {
|
|
86
|
-
log.info('[auth] No bearer token
|
|
105
|
+
log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth');
|
|
87
106
|
}
|
|
88
107
|
req.token = token;
|
|
89
108
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Logger } from '@pgpmjs/logger';
|
|
2
|
+
import './types'; // for Request type
|
|
3
|
+
const log = new Logger('captcha');
|
|
4
|
+
/** Google reCAPTCHA verification endpoint */
|
|
5
|
+
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
|
6
|
+
/**
|
|
7
|
+
* Header name the client sends the CAPTCHA response token in.
|
|
8
|
+
* Follows the common pattern: X-Captcha-Token.
|
|
9
|
+
*/
|
|
10
|
+
const CAPTCHA_HEADER = 'x-captcha-token';
|
|
11
|
+
/**
|
|
12
|
+
* GraphQL mutation names that require CAPTCHA verification when enabled.
|
|
13
|
+
* Only sign-up and password-reset are gated; normal sign-in is not.
|
|
14
|
+
*/
|
|
15
|
+
const CAPTCHA_PROTECTED_OPERATIONS = new Set([
|
|
16
|
+
'signUp',
|
|
17
|
+
'signUpWithMagicLink',
|
|
18
|
+
'signUpWithSms',
|
|
19
|
+
'resetPassword',
|
|
20
|
+
'requestPasswordReset',
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Attempt to extract the GraphQL operation name from the request body.
|
|
24
|
+
* Works for both JSON and already-parsed bodies.
|
|
25
|
+
*/
|
|
26
|
+
const getOperationName = (req) => {
|
|
27
|
+
const body = req.body;
|
|
28
|
+
if (!body)
|
|
29
|
+
return undefined;
|
|
30
|
+
// Already parsed (express.json ran first)
|
|
31
|
+
if (typeof body === 'object' && body.operationName) {
|
|
32
|
+
return body.operationName;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Verify a reCAPTCHA token with Google's API.
|
|
38
|
+
*/
|
|
39
|
+
const verifyToken = async (token, secretKey) => {
|
|
40
|
+
try {
|
|
41
|
+
const params = new URLSearchParams({ secret: secretKey, response: token });
|
|
42
|
+
const res = await fetch(RECAPTCHA_VERIFY_URL, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: params,
|
|
45
|
+
});
|
|
46
|
+
const data = (await res.json());
|
|
47
|
+
if (!data.success) {
|
|
48
|
+
log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
|
|
49
|
+
}
|
|
50
|
+
return data.success;
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
log.error('[captcha] Error verifying token:', e.message);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Creates a CAPTCHA verification middleware.
|
|
59
|
+
*
|
|
60
|
+
* When `enable_captcha` is true in app_auth_settings, this middleware checks
|
|
61
|
+
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
|
|
62
|
+
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
|
|
63
|
+
* (the public site key is stored in app_auth_settings for the frontend).
|
|
64
|
+
*
|
|
65
|
+
* Skips verification when:
|
|
66
|
+
* - CAPTCHA is not enabled in auth settings
|
|
67
|
+
* - The request is not a protected mutation
|
|
68
|
+
* - No secret key is configured server-side
|
|
69
|
+
*/
|
|
70
|
+
export const createCaptchaMiddleware = () => {
|
|
71
|
+
return async (req, res, next) => {
|
|
72
|
+
const authSettings = req.api?.authSettings;
|
|
73
|
+
// Skip if CAPTCHA is not enabled
|
|
74
|
+
if (!authSettings?.enableCaptcha) {
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
// Only gate protected operations
|
|
78
|
+
const opName = getOperationName(req);
|
|
79
|
+
if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
|
|
80
|
+
return next();
|
|
81
|
+
}
|
|
82
|
+
// Secret key must be set server-side (env var, not stored in DB for security)
|
|
83
|
+
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
|
|
84
|
+
if (!secretKey) {
|
|
85
|
+
log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
|
|
86
|
+
return next();
|
|
87
|
+
}
|
|
88
|
+
const captchaToken = req.get(CAPTCHA_HEADER);
|
|
89
|
+
if (!captchaToken) {
|
|
90
|
+
res.status(200).json({
|
|
91
|
+
errors: [{
|
|
92
|
+
message: 'CAPTCHA verification required',
|
|
93
|
+
extensions: { code: 'CAPTCHA_REQUIRED' },
|
|
94
|
+
}],
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const valid = await verifyToken(captchaToken, secretKey);
|
|
99
|
+
if (!valid) {
|
|
100
|
+
res.status(200).json({
|
|
101
|
+
errors: [{
|
|
102
|
+
message: 'CAPTCHA verification failed',
|
|
103
|
+
extensions: { code: 'CAPTCHA_FAILED' },
|
|
104
|
+
}],
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
log.info(`[captcha] Verified for operation=${opName}`);
|
|
109
|
+
next();
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -158,14 +158,25 @@ const buildPreset = (pool, schemas, anonRole, roleName) => {
|
|
|
158
158
|
context['jwt.claims.user_agent'] = req.get('User-Agent');
|
|
159
159
|
}
|
|
160
160
|
if (req.token?.user_id) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
...context,
|
|
167
|
-
},
|
|
161
|
+
const pgSettings = {
|
|
162
|
+
role: roleName,
|
|
163
|
+
'jwt.claims.token_id': req.token.id,
|
|
164
|
+
'jwt.claims.user_id': req.token.user_id,
|
|
165
|
+
...context,
|
|
168
166
|
};
|
|
167
|
+
// Propagate credential metadata as JWT claims so PG functions
|
|
168
|
+
// can read them via current_setting('jwt.claims.access_level') etc.
|
|
169
|
+
if (req.token.access_level) {
|
|
170
|
+
pgSettings['jwt.claims.access_level'] = req.token.access_level;
|
|
171
|
+
}
|
|
172
|
+
if (req.token.kind) {
|
|
173
|
+
pgSettings['jwt.claims.kind'] = req.token.kind;
|
|
174
|
+
}
|
|
175
|
+
// Enforce read-only transactions for read_only credentials (API keys, etc.)
|
|
176
|
+
if (req.token.access_level === 'read_only') {
|
|
177
|
+
pgSettings['default_transaction_read_only'] = 'on';
|
|
178
|
+
}
|
|
179
|
+
return { pgSettings };
|
|
169
180
|
}
|
|
170
181
|
}
|
|
171
182
|
return {
|
package/esm/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
|
|
|
21
21
|
import { debugMemory } from './middleware/observability/debug-memory';
|
|
22
22
|
import { localObservabilityOnly } from './middleware/observability/guard';
|
|
23
23
|
import { createRequestLogger } from './middleware/observability/request-logger';
|
|
24
|
+
import { createCaptchaMiddleware } from './middleware/captcha';
|
|
24
25
|
import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
|
|
25
26
|
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
26
27
|
const log = new Logger('server');
|
|
@@ -132,6 +133,7 @@ class Server {
|
|
|
132
133
|
app.use(api);
|
|
133
134
|
app.post('/upload', uploadAuthenticate, ...uploadRoute);
|
|
134
135
|
app.use(authenticate);
|
|
136
|
+
app.use(createCaptchaMiddleware());
|
|
135
137
|
app.use(graphile(effectiveOpts));
|
|
136
138
|
app.use(flush);
|
|
137
139
|
// Error handling - MUST be LAST
|
package/middleware/api.js
CHANGED
|
@@ -81,6 +81,34 @@ const RLS_MODULE_SQL = `
|
|
|
81
81
|
WHERE api_id = $1 AND name = 'rls_module'
|
|
82
82
|
LIMIT 1
|
|
83
83
|
`;
|
|
84
|
+
/**
|
|
85
|
+
* Discover auth settings table location via public metaschema tables.
|
|
86
|
+
* Joins sessions_module with metaschema_public.schema to resolve
|
|
87
|
+
* the schema name + table name without touching private schemas.
|
|
88
|
+
*/
|
|
89
|
+
const AUTH_SETTINGS_DISCOVERY_SQL = `
|
|
90
|
+
SELECT s.schema_name, sm.auth_settings_table AS table_name
|
|
91
|
+
FROM metaschema_modules_public.sessions_module sm
|
|
92
|
+
JOIN metaschema_public.schema s ON s.id = sm.schema_id
|
|
93
|
+
LIMIT 1
|
|
94
|
+
`;
|
|
95
|
+
/**
|
|
96
|
+
* Query auth settings from the discovered table.
|
|
97
|
+
* Schema and table name are resolved dynamically from metaschema modules.
|
|
98
|
+
*/
|
|
99
|
+
const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
|
|
100
|
+
SELECT
|
|
101
|
+
cookie_secure,
|
|
102
|
+
cookie_samesite,
|
|
103
|
+
cookie_domain,
|
|
104
|
+
cookie_httponly,
|
|
105
|
+
cookie_max_age,
|
|
106
|
+
cookie_path,
|
|
107
|
+
enable_captcha,
|
|
108
|
+
captcha_site_key
|
|
109
|
+
FROM "${schemaName}"."${tableName}"
|
|
110
|
+
LIMIT 1
|
|
111
|
+
`;
|
|
84
112
|
// =============================================================================
|
|
85
113
|
// Helpers
|
|
86
114
|
// =============================================================================
|
|
@@ -135,7 +163,21 @@ const toRlsModule = (row) => {
|
|
|
135
163
|
currentUserAgent: d.current_user_agent,
|
|
136
164
|
};
|
|
137
165
|
};
|
|
138
|
-
const
|
|
166
|
+
const toAuthSettings = (row) => {
|
|
167
|
+
if (!row)
|
|
168
|
+
return undefined;
|
|
169
|
+
return {
|
|
170
|
+
cookieSecure: row.cookie_secure,
|
|
171
|
+
cookieSamesite: row.cookie_samesite,
|
|
172
|
+
cookieDomain: row.cookie_domain,
|
|
173
|
+
cookieHttponly: row.cookie_httponly,
|
|
174
|
+
cookieMaxAge: row.cookie_max_age,
|
|
175
|
+
cookiePath: row.cookie_path,
|
|
176
|
+
enableCaptcha: row.enable_captcha,
|
|
177
|
+
captchaSiteKey: row.captcha_site_key,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
const toApiStructure = (row, opts, rlsModuleRow, authSettingsRow) => ({
|
|
139
181
|
apiId: row.api_id,
|
|
140
182
|
dbname: row.dbname || opts.pg?.database || '',
|
|
141
183
|
anonRole: row.anon_role || 'anon',
|
|
@@ -146,6 +188,7 @@ const toApiStructure = (row, opts, rlsModuleRow) => ({
|
|
|
146
188
|
domains: [],
|
|
147
189
|
databaseId: row.database_id,
|
|
148
190
|
isPublic: row.is_public,
|
|
191
|
+
authSettings: toAuthSettings(authSettingsRow ?? null),
|
|
149
192
|
});
|
|
150
193
|
const createAdminStructure = (opts, schemas, databaseId) => ({
|
|
151
194
|
dbname: opts.pg?.database ?? '',
|
|
@@ -180,6 +223,32 @@ const queryRlsModule = async (pool, apiId) => {
|
|
|
180
223
|
const result = await pool.query(RLS_MODULE_SQL, [apiId]);
|
|
181
224
|
return result.rows[0] ?? null;
|
|
182
225
|
};
|
|
226
|
+
/**
|
|
227
|
+
* Load server-relevant auth settings from the tenant DB.
|
|
228
|
+
* Discovers the auth settings table dynamically by joining
|
|
229
|
+
* metaschema_modules_public.sessions_module with metaschema_public.schema
|
|
230
|
+
* (both public schemas). Fails gracefully if modules or table don't exist yet.
|
|
231
|
+
*/
|
|
232
|
+
const queryAuthSettings = async (opts, dbname) => {
|
|
233
|
+
try {
|
|
234
|
+
const tenantPool = (0, pg_cache_1.getPgPool)({ ...opts.pg, database: dbname });
|
|
235
|
+
// Discover the auth settings schema + table name from public metaschema tables
|
|
236
|
+
const discovery = await tenantPool.query(AUTH_SETTINGS_DISCOVERY_SQL);
|
|
237
|
+
const resolved = discovery.rows[0];
|
|
238
|
+
if (!resolved) {
|
|
239
|
+
log.debug('[auth-settings] No sessions_module row found in tenant DB');
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
// Query the discovered auth settings table
|
|
243
|
+
const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
|
|
244
|
+
return result.rows[0] ?? null;
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
// Table/module may not exist yet if the 2FA migration hasn't been applied
|
|
248
|
+
log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
183
252
|
// =============================================================================
|
|
184
253
|
// Resolution Logic
|
|
185
254
|
// =============================================================================
|
|
@@ -231,8 +300,9 @@ const resolveApiNameHeader = async (ctx) => {
|
|
|
231
300
|
return null;
|
|
232
301
|
}
|
|
233
302
|
const rlsModule = await queryRlsModule(pool, row.api_id);
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
const authSettings = await queryAuthSettings(opts, row.dbname);
|
|
304
|
+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
|
|
305
|
+
return toApiStructure(row, opts, rlsModule, authSettings);
|
|
236
306
|
};
|
|
237
307
|
const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
|
|
238
308
|
return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
|
|
@@ -247,8 +317,9 @@ const resolveDomainLookup = async (ctx) => {
|
|
|
247
317
|
return null;
|
|
248
318
|
}
|
|
249
319
|
const rlsModule = await queryRlsModule(pool, row.api_id);
|
|
250
|
-
|
|
251
|
-
|
|
320
|
+
const authSettings = await queryAuthSettings(opts, row.dbname);
|
|
321
|
+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
|
|
322
|
+
return toApiStructure(row, opts, rlsModule, authSettings);
|
|
252
323
|
};
|
|
253
324
|
const buildDevFallbackError = async (ctx, req) => {
|
|
254
325
|
if ((0, env_1.getNodeEnv)() !== 'development')
|
package/middleware/auth.js
CHANGED
|
@@ -11,6 +11,19 @@ const pg_query_context_1 = __importDefault(require("pg-query-context"));
|
|
|
11
11
|
require("./types"); // for Request type
|
|
12
12
|
const log = new logger_1.Logger('auth');
|
|
13
13
|
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
14
|
+
/** Default cookie name for session tokens. */
|
|
15
|
+
const SESSION_COOKIE_NAME = 'constructive_session';
|
|
16
|
+
/**
|
|
17
|
+
* Extract a named cookie value from the raw Cookie header.
|
|
18
|
+
* Avoids pulling in cookie-parser as a dependency.
|
|
19
|
+
*/
|
|
20
|
+
const parseCookieToken = (req, cookieName) => {
|
|
21
|
+
const header = req.headers.cookie;
|
|
22
|
+
if (!header)
|
|
23
|
+
return undefined;
|
|
24
|
+
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
|
|
25
|
+
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
|
|
26
|
+
};
|
|
14
27
|
const createAuthenticateMiddleware = (opts) => {
|
|
15
28
|
return async (req, res, next) => {
|
|
16
29
|
const api = req.api;
|
|
@@ -42,8 +55,14 @@ const createAuthenticateMiddleware = (opts) => {
|
|
|
42
55
|
let token = {};
|
|
43
56
|
log.info(`[auth] authorization header present=${!!authorization}, ` +
|
|
44
57
|
`authType=${authType ?? 'none'}, hasToken=${!!authToken}`);
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
// Resolve the credential: prefer Bearer header, fall back to session cookie
|
|
59
|
+
const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME);
|
|
60
|
+
const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken)
|
|
61
|
+
? authToken
|
|
62
|
+
: cookieToken;
|
|
63
|
+
const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none');
|
|
64
|
+
if (effectiveToken) {
|
|
65
|
+
log.info(`[auth] Processing ${tokenSource} authentication`);
|
|
47
66
|
const context = {
|
|
48
67
|
'jwt.claims.ip_address': req.clientIp,
|
|
49
68
|
};
|
|
@@ -60,7 +79,7 @@ const createAuthenticateMiddleware = (opts) => {
|
|
|
60
79
|
client: pool,
|
|
61
80
|
context,
|
|
62
81
|
query: authQuery,
|
|
63
|
-
variables: [
|
|
82
|
+
variables: [effectiveToken],
|
|
64
83
|
});
|
|
65
84
|
log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
|
|
66
85
|
if (result?.rowCount === 0) {
|
|
@@ -89,7 +108,7 @@ const createAuthenticateMiddleware = (opts) => {
|
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
110
|
else {
|
|
92
|
-
log.info('[auth] No bearer token
|
|
111
|
+
log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth');
|
|
93
112
|
}
|
|
94
113
|
req.token = token;
|
|
95
114
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
import './types';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a CAPTCHA verification middleware.
|
|
5
|
+
*
|
|
6
|
+
* When `enable_captcha` is true in app_auth_settings, this middleware checks
|
|
7
|
+
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
|
|
8
|
+
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
|
|
9
|
+
* (the public site key is stored in app_auth_settings for the frontend).
|
|
10
|
+
*
|
|
11
|
+
* Skips verification when:
|
|
12
|
+
* - CAPTCHA is not enabled in auth settings
|
|
13
|
+
* - The request is not a protected mutation
|
|
14
|
+
* - No secret key is configured server-side
|
|
15
|
+
*/
|
|
16
|
+
export declare const createCaptchaMiddleware: () => RequestHandler;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCaptchaMiddleware = void 0;
|
|
4
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
5
|
+
require("./types"); // for Request type
|
|
6
|
+
const log = new logger_1.Logger('captcha');
|
|
7
|
+
/** Google reCAPTCHA verification endpoint */
|
|
8
|
+
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
|
9
|
+
/**
|
|
10
|
+
* Header name the client sends the CAPTCHA response token in.
|
|
11
|
+
* Follows the common pattern: X-Captcha-Token.
|
|
12
|
+
*/
|
|
13
|
+
const CAPTCHA_HEADER = 'x-captcha-token';
|
|
14
|
+
/**
|
|
15
|
+
* GraphQL mutation names that require CAPTCHA verification when enabled.
|
|
16
|
+
* Only sign-up and password-reset are gated; normal sign-in is not.
|
|
17
|
+
*/
|
|
18
|
+
const CAPTCHA_PROTECTED_OPERATIONS = new Set([
|
|
19
|
+
'signUp',
|
|
20
|
+
'signUpWithMagicLink',
|
|
21
|
+
'signUpWithSms',
|
|
22
|
+
'resetPassword',
|
|
23
|
+
'requestPasswordReset',
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Attempt to extract the GraphQL operation name from the request body.
|
|
27
|
+
* Works for both JSON and already-parsed bodies.
|
|
28
|
+
*/
|
|
29
|
+
const getOperationName = (req) => {
|
|
30
|
+
const body = req.body;
|
|
31
|
+
if (!body)
|
|
32
|
+
return undefined;
|
|
33
|
+
// Already parsed (express.json ran first)
|
|
34
|
+
if (typeof body === 'object' && body.operationName) {
|
|
35
|
+
return body.operationName;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Verify a reCAPTCHA token with Google's API.
|
|
41
|
+
*/
|
|
42
|
+
const verifyToken = async (token, secretKey) => {
|
|
43
|
+
try {
|
|
44
|
+
const params = new URLSearchParams({ secret: secretKey, response: token });
|
|
45
|
+
const res = await fetch(RECAPTCHA_VERIFY_URL, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
body: params,
|
|
48
|
+
});
|
|
49
|
+
const data = (await res.json());
|
|
50
|
+
if (!data.success) {
|
|
51
|
+
log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
|
|
52
|
+
}
|
|
53
|
+
return data.success;
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
log.error('[captcha] Error verifying token:', e.message);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Creates a CAPTCHA verification middleware.
|
|
62
|
+
*
|
|
63
|
+
* When `enable_captcha` is true in app_auth_settings, this middleware checks
|
|
64
|
+
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
|
|
65
|
+
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
|
|
66
|
+
* (the public site key is stored in app_auth_settings for the frontend).
|
|
67
|
+
*
|
|
68
|
+
* Skips verification when:
|
|
69
|
+
* - CAPTCHA is not enabled in auth settings
|
|
70
|
+
* - The request is not a protected mutation
|
|
71
|
+
* - No secret key is configured server-side
|
|
72
|
+
*/
|
|
73
|
+
const createCaptchaMiddleware = () => {
|
|
74
|
+
return async (req, res, next) => {
|
|
75
|
+
const authSettings = req.api?.authSettings;
|
|
76
|
+
// Skip if CAPTCHA is not enabled
|
|
77
|
+
if (!authSettings?.enableCaptcha) {
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
80
|
+
// Only gate protected operations
|
|
81
|
+
const opName = getOperationName(req);
|
|
82
|
+
if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
|
|
83
|
+
return next();
|
|
84
|
+
}
|
|
85
|
+
// Secret key must be set server-side (env var, not stored in DB for security)
|
|
86
|
+
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
|
|
87
|
+
if (!secretKey) {
|
|
88
|
+
log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
|
|
89
|
+
return next();
|
|
90
|
+
}
|
|
91
|
+
const captchaToken = req.get(CAPTCHA_HEADER);
|
|
92
|
+
if (!captchaToken) {
|
|
93
|
+
res.status(200).json({
|
|
94
|
+
errors: [{
|
|
95
|
+
message: 'CAPTCHA verification required',
|
|
96
|
+
extensions: { code: 'CAPTCHA_REQUIRED' },
|
|
97
|
+
}],
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const valid = await verifyToken(captchaToken, secretKey);
|
|
102
|
+
if (!valid) {
|
|
103
|
+
res.status(200).json({
|
|
104
|
+
errors: [{
|
|
105
|
+
message: 'CAPTCHA verification failed',
|
|
106
|
+
extensions: { code: 'CAPTCHA_FAILED' },
|
|
107
|
+
}],
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
log.info(`[captcha] Verified for operation=${opName}`);
|
|
112
|
+
next();
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
exports.createCaptchaMiddleware = createCaptchaMiddleware;
|
package/middleware/graphile.js
CHANGED
|
@@ -167,14 +167,25 @@ const buildPreset = (pool, schemas, anonRole, roleName) => {
|
|
|
167
167
|
context['jwt.claims.user_agent'] = req.get('User-Agent');
|
|
168
168
|
}
|
|
169
169
|
if (req.token?.user_id) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
...context,
|
|
176
|
-
},
|
|
170
|
+
const pgSettings = {
|
|
171
|
+
role: roleName,
|
|
172
|
+
'jwt.claims.token_id': req.token.id,
|
|
173
|
+
'jwt.claims.user_id': req.token.user_id,
|
|
174
|
+
...context,
|
|
177
175
|
};
|
|
176
|
+
// Propagate credential metadata as JWT claims so PG functions
|
|
177
|
+
// can read them via current_setting('jwt.claims.access_level') etc.
|
|
178
|
+
if (req.token.access_level) {
|
|
179
|
+
pgSettings['jwt.claims.access_level'] = req.token.access_level;
|
|
180
|
+
}
|
|
181
|
+
if (req.token.kind) {
|
|
182
|
+
pgSettings['jwt.claims.kind'] = req.token.kind;
|
|
183
|
+
}
|
|
184
|
+
// Enforce read-only transactions for read_only credentials (API keys, etc.)
|
|
185
|
+
if (req.token.access_level === 'read_only') {
|
|
186
|
+
pgSettings['default_transaction_read_only'] = 'on';
|
|
187
|
+
}
|
|
188
|
+
return { pgSettings };
|
|
178
189
|
}
|
|
179
190
|
}
|
|
180
191
|
return {
|
package/middleware/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.18.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -41,38 +41,38 @@
|
|
|
41
41
|
"backend"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@constructive-io/graphql-env": "^3.
|
|
45
|
-
"@constructive-io/graphql-types": "^3.
|
|
46
|
-
"@constructive-io/s3-utils": "^2.
|
|
47
|
-
"@constructive-io/upload-names": "^2.
|
|
48
|
-
"@constructive-io/url-domains": "^2.
|
|
44
|
+
"@constructive-io/graphql-env": "^3.6.0",
|
|
45
|
+
"@constructive-io/graphql-types": "^3.5.0",
|
|
46
|
+
"@constructive-io/s3-utils": "^2.11.0",
|
|
47
|
+
"@constructive-io/upload-names": "^2.11.0",
|
|
48
|
+
"@constructive-io/url-domains": "^2.11.0",
|
|
49
49
|
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
|
|
50
50
|
"@graphile/simplify-inflection": "8.0.0",
|
|
51
|
-
"@pgpmjs/env": "^2.
|
|
52
|
-
"@pgpmjs/logger": "^2.
|
|
53
|
-
"@pgpmjs/server-utils": "^3.
|
|
54
|
-
"@pgpmjs/types": "^2.
|
|
51
|
+
"@pgpmjs/env": "^2.18.0",
|
|
52
|
+
"@pgpmjs/logger": "^2.6.0",
|
|
53
|
+
"@pgpmjs/server-utils": "^3.6.0",
|
|
54
|
+
"@pgpmjs/types": "^2.22.0",
|
|
55
55
|
"@pgsql/quotes": "^17.1.0",
|
|
56
56
|
"cors": "^2.8.6",
|
|
57
57
|
"deepmerge": "^4.3.1",
|
|
58
58
|
"express": "^5.2.1",
|
|
59
|
-
"gql-ast": "^3.
|
|
59
|
+
"gql-ast": "^3.5.0",
|
|
60
60
|
"grafast": "1.0.0",
|
|
61
61
|
"grafserv": "1.0.0",
|
|
62
62
|
"graphile-build": "5.0.0",
|
|
63
63
|
"graphile-build-pg": "5.0.0",
|
|
64
|
-
"graphile-cache": "^3.
|
|
64
|
+
"graphile-cache": "^3.5.0",
|
|
65
65
|
"graphile-config": "1.0.0",
|
|
66
|
-
"graphile-settings": "^4.
|
|
66
|
+
"graphile-settings": "^4.21.0",
|
|
67
67
|
"graphile-utils": "5.0.0",
|
|
68
68
|
"graphql": "16.13.0",
|
|
69
69
|
"graphql-upload": "^13.0.0",
|
|
70
70
|
"lru-cache": "^11.2.7",
|
|
71
71
|
"multer": "^2.1.1",
|
|
72
72
|
"pg": "^8.20.0",
|
|
73
|
-
"pg-cache": "^3.
|
|
74
|
-
"pg-env": "^1.
|
|
75
|
-
"pg-query-context": "^2.
|
|
73
|
+
"pg-cache": "^3.5.0",
|
|
74
|
+
"pg-env": "^1.9.0",
|
|
75
|
+
"pg-query-context": "^2.10.0",
|
|
76
76
|
"pg-sql2": "5.0.0",
|
|
77
77
|
"postgraphile": "5.0.0",
|
|
78
78
|
"request-ip": "^3.3.0"
|
|
@@ -85,10 +85,10 @@
|
|
|
85
85
|
"@types/multer": "^2.1.0",
|
|
86
86
|
"@types/pg": "^8.18.0",
|
|
87
87
|
"@types/request-ip": "^0.0.41",
|
|
88
|
-
"graphile-test": "4.
|
|
88
|
+
"graphile-test": "4.9.0",
|
|
89
89
|
"makage": "^0.3.0",
|
|
90
90
|
"nodemon": "^3.1.14",
|
|
91
91
|
"ts-node": "^10.9.2"
|
|
92
92
|
},
|
|
93
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "1b3af3c5189b9ca2e765b9239a4b287099e64a03"
|
|
94
94
|
}
|
package/server.js
CHANGED
|
@@ -27,6 +27,7 @@ const debug_db_1 = require("./middleware/observability/debug-db");
|
|
|
27
27
|
const debug_memory_1 = require("./middleware/observability/debug-memory");
|
|
28
28
|
const guard_1 = require("./middleware/observability/guard");
|
|
29
29
|
const request_logger_1 = require("./middleware/observability/request-logger");
|
|
30
|
+
const captcha_1 = require("./middleware/captcha");
|
|
30
31
|
const upload_1 = require("./middleware/upload");
|
|
31
32
|
const debug_sampler_1 = require("./diagnostics/debug-sampler");
|
|
32
33
|
const log = new logger_1.Logger('server');
|
|
@@ -139,6 +140,7 @@ class Server {
|
|
|
139
140
|
app.use(api);
|
|
140
141
|
app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
|
|
141
142
|
app.use(authenticate);
|
|
143
|
+
app.use((0, captcha_1.createCaptchaMiddleware)());
|
|
142
144
|
app.use((0, graphile_1.graphile)(effectiveOpts));
|
|
143
145
|
app.use(flush_1.flush);
|
|
144
146
|
// Error handling - MUST be LAST
|
package/types.d.ts
CHANGED
|
@@ -38,6 +38,23 @@ export interface RlsModule {
|
|
|
38
38
|
currentIpAddress: string;
|
|
39
39
|
currentUserAgent: string;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Server-visible subset of app_auth_settings (lives in the tenant DB private schema).
|
|
43
|
+
* Discovered dynamically via metaschema_modules_public.sessions_module.
|
|
44
|
+
* Loaded once per API resolution and cached alongside the ApiStructure.
|
|
45
|
+
*/
|
|
46
|
+
export interface AuthSettings {
|
|
47
|
+
/** Cookie configuration */
|
|
48
|
+
cookieSecure?: boolean;
|
|
49
|
+
cookieSamesite?: string;
|
|
50
|
+
cookieDomain?: string | null;
|
|
51
|
+
cookieHttponly?: boolean;
|
|
52
|
+
cookieMaxAge?: string | null;
|
|
53
|
+
cookiePath?: string;
|
|
54
|
+
/** reCAPTCHA / CAPTCHA */
|
|
55
|
+
enableCaptcha?: boolean;
|
|
56
|
+
captchaSiteKey?: string | null;
|
|
57
|
+
}
|
|
41
58
|
export interface ApiStructure {
|
|
42
59
|
apiId?: string;
|
|
43
60
|
dbname: string;
|
|
@@ -49,6 +66,7 @@ export interface ApiStructure {
|
|
|
49
66
|
domains?: string[];
|
|
50
67
|
databaseId?: string;
|
|
51
68
|
isPublic?: boolean;
|
|
69
|
+
authSettings?: AuthSettings;
|
|
52
70
|
}
|
|
53
71
|
export type ApiError = {
|
|
54
72
|
errorHtml: string;
|