@constructive-io/graphql-server 4.29.1 → 4.30.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 +6 -1
- package/esm/middleware/auth.js +8 -0
- package/esm/middleware/cookie.js +114 -0
- package/esm/middleware/error-handler.js +8 -0
- package/esm/middleware/graphile.js +5 -0
- package/esm/plugins/auth-cookie-plugin.js +286 -0
- package/esm/server.js +33 -0
- package/middleware/api.js +6 -1
- package/middleware/auth.js +8 -0
- package/middleware/cookie.d.ts +49 -0
- package/middleware/cookie.js +126 -0
- package/middleware/error-handler.js +8 -0
- package/middleware/graphile.js +5 -0
- package/middleware/types.d.ts +2 -0
- package/package.json +7 -4
- package/plugins/auth-cookie-plugin.d.ts +11 -0
- package/plugins/auth-cookie-plugin.js +289 -0
- package/server.js +33 -0
- package/types.d.ts +3 -0
package/esm/middleware/api.js
CHANGED
|
@@ -120,6 +120,7 @@ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
|
|
|
120
120
|
cookie_httponly,
|
|
121
121
|
cookie_max_age,
|
|
122
122
|
cookie_path,
|
|
123
|
+
remember_me_duration,
|
|
123
124
|
enable_captcha,
|
|
124
125
|
captcha_site_key
|
|
125
126
|
FROM "${schemaName}"."${tableName}"
|
|
@@ -198,6 +199,7 @@ const DATABASE_SETTINGS_SQL = `
|
|
|
198
199
|
ds.enable_connection_filter,
|
|
199
200
|
ds.enable_ltree,
|
|
200
201
|
ds.enable_llm,
|
|
202
|
+
ds.enable_bulk,
|
|
201
203
|
COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
|
|
202
204
|
COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
|
|
203
205
|
COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
|
|
@@ -207,7 +209,8 @@ const DATABASE_SETTINGS_SQL = `
|
|
|
207
209
|
COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
|
|
208
210
|
COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
|
|
209
211
|
COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
|
|
210
|
-
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime
|
|
212
|
+
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
|
|
213
|
+
COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
|
|
211
214
|
FROM services_public.database_settings ds
|
|
212
215
|
LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
|
|
213
216
|
WHERE ds.database_id = $1
|
|
@@ -298,6 +301,7 @@ const toAuthSettings = (row) => {
|
|
|
298
301
|
cookieHttponly: row.cookie_httponly,
|
|
299
302
|
cookieMaxAge: row.cookie_max_age,
|
|
300
303
|
cookiePath: row.cookie_path,
|
|
304
|
+
rememberMeDuration: row.remember_me_duration,
|
|
301
305
|
enableCaptcha: row.enable_captcha,
|
|
302
306
|
captchaSiteKey: row.captcha_site_key,
|
|
303
307
|
};
|
|
@@ -487,6 +491,7 @@ const toDatabaseSettings = (row) => {
|
|
|
487
491
|
enableLtree: row.resolved_enable_ltree,
|
|
488
492
|
enableLlm: row.resolved_enable_llm,
|
|
489
493
|
enableRealtime: row.resolved_enable_realtime,
|
|
494
|
+
enableBulk: row.resolved_enable_bulk,
|
|
490
495
|
};
|
|
491
496
|
};
|
|
492
497
|
const queryDatabaseSettings = async (pool, databaseId, apiId) => {
|
package/esm/middleware/auth.js
CHANGED
|
@@ -7,6 +7,8 @@ const log = new Logger('auth');
|
|
|
7
7
|
const isDev = () => getNodeEnv() === 'development';
|
|
8
8
|
/** Default cookie name for session tokens. */
|
|
9
9
|
const SESSION_COOKIE_NAME = 'constructive_session';
|
|
10
|
+
/** Cookie name for trusted device tracking. */
|
|
11
|
+
const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
|
|
10
12
|
/**
|
|
11
13
|
* Extract a named cookie value from the raw Cookie header.
|
|
12
14
|
* Avoids pulling in cookie-parser as a dependency.
|
|
@@ -110,6 +112,12 @@ export const createAuthenticateMiddleware = (opts) => {
|
|
|
110
112
|
log.info(`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
|
|
111
113
|
`privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}`);
|
|
112
114
|
}
|
|
115
|
+
// Read device token cookie for trusted device tracking
|
|
116
|
+
const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
|
|
117
|
+
if (deviceToken) {
|
|
118
|
+
req.deviceToken = deviceToken;
|
|
119
|
+
log.info('[auth] Device token cookie present');
|
|
120
|
+
}
|
|
113
121
|
next();
|
|
114
122
|
};
|
|
115
123
|
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export const SESSION_COOKIE_NAME = 'constructive_session';
|
|
2
|
+
export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
|
|
3
|
+
const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
4
|
+
/**
|
|
5
|
+
* Build cookie config from AuthSettings with optional remember_me override.
|
|
6
|
+
*/
|
|
7
|
+
export const getSessionCookieConfig = (authSettings, rememberMe = false) => {
|
|
8
|
+
const DEFAULT_MAX_AGE = 86400; // 24 hours
|
|
9
|
+
let maxAge = DEFAULT_MAX_AGE;
|
|
10
|
+
if (rememberMe && authSettings?.rememberMeDuration) {
|
|
11
|
+
const parsed = parseInt(authSettings.rememberMeDuration, 10);
|
|
12
|
+
if (!isNaN(parsed))
|
|
13
|
+
maxAge = parsed;
|
|
14
|
+
}
|
|
15
|
+
else if (authSettings?.cookieMaxAge) {
|
|
16
|
+
const parsed = parseInt(authSettings.cookieMaxAge, 10);
|
|
17
|
+
if (!isNaN(parsed))
|
|
18
|
+
maxAge = parsed;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
|
|
22
|
+
sameSite: authSettings?.cookieSamesite ?? 'lax',
|
|
23
|
+
domain: authSettings?.cookieDomain ?? undefined,
|
|
24
|
+
httpOnly: authSettings?.cookieHttponly ?? true,
|
|
25
|
+
maxAge,
|
|
26
|
+
path: authSettings?.cookiePath ?? '/',
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Build cookie config for device token (long-lived, 90 days).
|
|
31
|
+
*/
|
|
32
|
+
export const getDeviceTokenCookieConfig = (authSettings) => {
|
|
33
|
+
return {
|
|
34
|
+
secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
|
|
35
|
+
sameSite: authSettings?.cookieSamesite ?? 'lax',
|
|
36
|
+
domain: authSettings?.cookieDomain ?? undefined,
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
maxAge: DEVICE_TOKEN_MAX_AGE,
|
|
39
|
+
path: authSettings?.cookiePath ?? '/',
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Set the session cookie with the access token.
|
|
44
|
+
*/
|
|
45
|
+
export const setSessionCookie = (res, accessToken, config) => {
|
|
46
|
+
res.cookie(SESSION_COOKIE_NAME, accessToken, {
|
|
47
|
+
secure: config.secure,
|
|
48
|
+
sameSite: config.sameSite,
|
|
49
|
+
domain: config.domain,
|
|
50
|
+
httpOnly: config.httpOnly,
|
|
51
|
+
maxAge: config.maxAge * 1000, // Express expects milliseconds
|
|
52
|
+
path: config.path,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Clear the session cookie.
|
|
57
|
+
*/
|
|
58
|
+
export const clearSessionCookie = (res, config) => {
|
|
59
|
+
res.clearCookie(SESSION_COOKIE_NAME, {
|
|
60
|
+
secure: config.secure,
|
|
61
|
+
sameSite: config.sameSite,
|
|
62
|
+
domain: config.domain,
|
|
63
|
+
httpOnly: config.httpOnly,
|
|
64
|
+
path: config.path,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Set the device token cookie (long-lived for trusted device tracking).
|
|
69
|
+
*/
|
|
70
|
+
export const setDeviceTokenCookie = (res, deviceToken, config) => {
|
|
71
|
+
res.cookie(DEVICE_TOKEN_COOKIE_NAME, deviceToken, {
|
|
72
|
+
secure: config.secure,
|
|
73
|
+
sameSite: config.sameSite,
|
|
74
|
+
domain: config.domain,
|
|
75
|
+
httpOnly: config.httpOnly,
|
|
76
|
+
maxAge: config.maxAge * 1000,
|
|
77
|
+
path: config.path,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Clear the device token cookie.
|
|
82
|
+
*/
|
|
83
|
+
export const clearDeviceTokenCookie = (res, config) => {
|
|
84
|
+
res.clearCookie(DEVICE_TOKEN_COOKIE_NAME, {
|
|
85
|
+
secure: config.secure,
|
|
86
|
+
sameSite: config.sameSite,
|
|
87
|
+
domain: config.domain,
|
|
88
|
+
httpOnly: config.httpOnly,
|
|
89
|
+
path: config.path,
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Parse a cookie value from the raw Cookie header.
|
|
94
|
+
* Avoids pulling in cookie-parser as a dependency.
|
|
95
|
+
*/
|
|
96
|
+
export const parseCookieValue = (req, cookieName) => {
|
|
97
|
+
const header = req.headers.cookie;
|
|
98
|
+
if (!header)
|
|
99
|
+
return undefined;
|
|
100
|
+
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
|
|
101
|
+
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Get the device token from the request cookie.
|
|
105
|
+
*/
|
|
106
|
+
export const getDeviceTokenFromRequest = (req) => {
|
|
107
|
+
return parseCookieValue(req, DEVICE_TOKEN_COOKIE_NAME);
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Get the session token from the request cookie.
|
|
111
|
+
*/
|
|
112
|
+
export const getSessionTokenFromRequest = (req) => {
|
|
113
|
+
return parseCookieValue(req, SESSION_COOKIE_NAME);
|
|
114
|
+
};
|
|
@@ -23,6 +23,10 @@ const sanitizeMessage = (error) => {
|
|
|
23
23
|
return 'The requested resource does not exist';
|
|
24
24
|
return 'An unexpected error occurred';
|
|
25
25
|
};
|
|
26
|
+
const isCsrfError = (err) => {
|
|
27
|
+
const code = err.code;
|
|
28
|
+
return typeof code === 'string' && code.startsWith('CSRF_');
|
|
29
|
+
};
|
|
26
30
|
const categorizeError = (err) => {
|
|
27
31
|
if (isApiError(err)) {
|
|
28
32
|
return {
|
|
@@ -32,6 +36,10 @@ const categorizeError = (err) => {
|
|
|
32
36
|
logLevel: err.statusCode >= 500 ? 'error' : 'warn',
|
|
33
37
|
};
|
|
34
38
|
}
|
|
39
|
+
if (isCsrfError(err)) {
|
|
40
|
+
const code = err.code;
|
|
41
|
+
return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
|
|
42
|
+
}
|
|
35
43
|
if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
|
|
36
44
|
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
|
|
37
45
|
}
|
|
@@ -9,6 +9,7 @@ import './types'; // for Request type
|
|
|
9
9
|
import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
10
10
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
11
11
|
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
12
|
+
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
12
13
|
const maskErrorLog = new Logger('graphile:maskError');
|
|
13
14
|
const SAFE_ERROR_CODES = new Set([
|
|
14
15
|
// GraphQL standard
|
|
@@ -186,6 +187,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
186
187
|
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
187
188
|
return {
|
|
188
189
|
extends: [createConstructivePreset(databaseSettings)],
|
|
190
|
+
plugins: [AuthCookiePlugin],
|
|
189
191
|
pgServices: [
|
|
190
192
|
makePgService({
|
|
191
193
|
pool,
|
|
@@ -218,6 +220,9 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
|
218
220
|
if (req.get('User-Agent')) {
|
|
219
221
|
context['jwt.claims.user_agent'] = req.get('User-Agent');
|
|
220
222
|
}
|
|
223
|
+
if (req.deviceToken) {
|
|
224
|
+
context['jwt.claims.device_token'] = req.deviceToken;
|
|
225
|
+
}
|
|
221
226
|
if (req.token?.user_id) {
|
|
222
227
|
const pgSettings = {
|
|
223
228
|
role: roleName,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Logger } from '@pgpmjs/logger';
|
|
2
|
+
import '../middleware/types';
|
|
3
|
+
import { SESSION_COOKIE_NAME, DEVICE_TOKEN_COOKIE_NAME, getSessionCookieConfig, getDeviceTokenCookieConfig, } from '../middleware/cookie';
|
|
4
|
+
const log = new Logger('auth-cookie');
|
|
5
|
+
/**
|
|
6
|
+
* Serialize a cookie to a Set-Cookie header value.
|
|
7
|
+
*/
|
|
8
|
+
const serializeCookie = (name, value, config) => {
|
|
9
|
+
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
10
|
+
if (config.maxAge !== undefined) {
|
|
11
|
+
parts.push(`Max-Age=${config.maxAge}`);
|
|
12
|
+
}
|
|
13
|
+
if (config.domain) {
|
|
14
|
+
parts.push(`Domain=${config.domain}`);
|
|
15
|
+
}
|
|
16
|
+
if (config.path) {
|
|
17
|
+
parts.push(`Path=${config.path}`);
|
|
18
|
+
}
|
|
19
|
+
if (config.secure) {
|
|
20
|
+
parts.push('Secure');
|
|
21
|
+
}
|
|
22
|
+
if (config.httpOnly) {
|
|
23
|
+
parts.push('HttpOnly');
|
|
24
|
+
}
|
|
25
|
+
if (config.sameSite) {
|
|
26
|
+
parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
|
|
27
|
+
}
|
|
28
|
+
return parts.join('; ');
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Serialize a cookie for clearing (expired).
|
|
32
|
+
*/
|
|
33
|
+
const serializeClearCookie = (name, config) => {
|
|
34
|
+
const parts = [`${encodeURIComponent(name)}=`];
|
|
35
|
+
parts.push('Max-Age=0');
|
|
36
|
+
if (config.domain) {
|
|
37
|
+
parts.push(`Domain=${config.domain}`);
|
|
38
|
+
}
|
|
39
|
+
if (config.path) {
|
|
40
|
+
parts.push(`Path=${config.path}`);
|
|
41
|
+
}
|
|
42
|
+
if (config.secure) {
|
|
43
|
+
parts.push('Secure');
|
|
44
|
+
}
|
|
45
|
+
if (config.httpOnly) {
|
|
46
|
+
parts.push('HttpOnly');
|
|
47
|
+
}
|
|
48
|
+
if (config.sameSite) {
|
|
49
|
+
parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
|
|
50
|
+
}
|
|
51
|
+
return parts.join('; ');
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Auth mutations that should set session cookie on success.
|
|
55
|
+
*/
|
|
56
|
+
const SIGN_IN_MUTATIONS = new Set([
|
|
57
|
+
'signIn',
|
|
58
|
+
'signUp',
|
|
59
|
+
'signInSso',
|
|
60
|
+
'signUpSso',
|
|
61
|
+
'signInMagicLink',
|
|
62
|
+
'signUpMagicLink',
|
|
63
|
+
'signInEmailOtp',
|
|
64
|
+
'signInSmsOtp',
|
|
65
|
+
'signUpSms',
|
|
66
|
+
'completeMfaChallenge',
|
|
67
|
+
'signInOneTimeToken',
|
|
68
|
+
'signInCrossOrigin',
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Auth mutations that should clear the session cookie.
|
|
72
|
+
*/
|
|
73
|
+
const SIGN_OUT_MUTATIONS = new Set([
|
|
74
|
+
'signOut',
|
|
75
|
+
'revokeSession',
|
|
76
|
+
'revokeAllSessions',
|
|
77
|
+
]);
|
|
78
|
+
/**
|
|
79
|
+
* Extract mutation names from a GraphQL query string.
|
|
80
|
+
*/
|
|
81
|
+
const extractMutationNames = (query) => {
|
|
82
|
+
const mutations = [];
|
|
83
|
+
if (!/^\s*mutation\b/i.test(query)) {
|
|
84
|
+
return mutations;
|
|
85
|
+
}
|
|
86
|
+
const bodyStart = query.indexOf('{');
|
|
87
|
+
if (bodyStart === -1)
|
|
88
|
+
return mutations;
|
|
89
|
+
const bodyContent = query.slice(bodyStart + 1);
|
|
90
|
+
const fieldPattern = /(\w+)\s*(?:\(|{)/g;
|
|
91
|
+
let match;
|
|
92
|
+
while ((match = fieldPattern.exec(bodyContent)) !== null) {
|
|
93
|
+
const name = match[1];
|
|
94
|
+
if (name !== 'mutation' && name !== 'query' && name !== 'fragment') {
|
|
95
|
+
mutations.push(name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return mutations;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Extract access token from mutation response.
|
|
102
|
+
*/
|
|
103
|
+
const extractAccessToken = (data, mutationName) => {
|
|
104
|
+
const result = data[mutationName];
|
|
105
|
+
if (!result)
|
|
106
|
+
return undefined;
|
|
107
|
+
// Check for non-empty string tokens
|
|
108
|
+
if (typeof result.accessToken === 'string' && result.accessToken)
|
|
109
|
+
return result.accessToken;
|
|
110
|
+
if (typeof result.access_token === 'string' && result.access_token)
|
|
111
|
+
return result.access_token;
|
|
112
|
+
const nested = result.result;
|
|
113
|
+
if (nested) {
|
|
114
|
+
if (typeof nested.accessToken === 'string' && nested.accessToken)
|
|
115
|
+
return nested.accessToken;
|
|
116
|
+
if (typeof nested.access_token === 'string' && nested.access_token)
|
|
117
|
+
return nested.access_token;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Extract device ID from mutation response.
|
|
123
|
+
*/
|
|
124
|
+
const extractDeviceId = (data, mutationName) => {
|
|
125
|
+
const result = data[mutationName];
|
|
126
|
+
if (!result)
|
|
127
|
+
return undefined;
|
|
128
|
+
if (typeof result.deviceId === 'string')
|
|
129
|
+
return result.deviceId;
|
|
130
|
+
if (typeof result.device_id === 'string')
|
|
131
|
+
return result.device_id;
|
|
132
|
+
const nested = result.result;
|
|
133
|
+
if (nested) {
|
|
134
|
+
if (typeof nested.deviceId === 'string')
|
|
135
|
+
return nested.deviceId;
|
|
136
|
+
if (typeof nested.device_id === 'string')
|
|
137
|
+
return nested.device_id;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Check if request includes remember_me flag.
|
|
143
|
+
*/
|
|
144
|
+
const hasRememberMe = (variables) => {
|
|
145
|
+
if (!variables)
|
|
146
|
+
return false;
|
|
147
|
+
return variables.rememberMe === true || variables.remember_me === true;
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Get Express request from grafserv request context.
|
|
151
|
+
*/
|
|
152
|
+
const getExpressRequest = (requestContext) => {
|
|
153
|
+
return requestContext?.expressv4?.req;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* AuthCookiePlugin - grafserv middleware plugin that handles auth cookie lifecycle.
|
|
157
|
+
*
|
|
158
|
+
* This plugin intercepts GraphQL responses and:
|
|
159
|
+
* - Sets session cookies on successful sign-in mutations
|
|
160
|
+
* - Clears session cookies on sign-out mutations
|
|
161
|
+
* - Handles device token cookies for trusted device tracking
|
|
162
|
+
*/
|
|
163
|
+
export const AuthCookiePlugin = {
|
|
164
|
+
name: 'AuthCookiePlugin',
|
|
165
|
+
version: '1.0.0',
|
|
166
|
+
grafserv: {
|
|
167
|
+
middleware: {
|
|
168
|
+
processRequest: {
|
|
169
|
+
callback: async (next, event) => {
|
|
170
|
+
const result = await next();
|
|
171
|
+
// Only process buffer results (JSON responses)
|
|
172
|
+
if (!result || result.type !== 'buffer') {
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
const bufferResult = result;
|
|
176
|
+
const req = getExpressRequest(event.requestDigest.requestContext);
|
|
177
|
+
// Skip if no Express request or not a POST
|
|
178
|
+
if (!req || event.requestDigest.method !== 'POST') {
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
// Get request body for mutation detection
|
|
182
|
+
// grafserv provides getBody() which returns { type: 'buffer', buffer: Buffer }
|
|
183
|
+
let body;
|
|
184
|
+
if (typeof event.requestDigest.getBody === 'function') {
|
|
185
|
+
try {
|
|
186
|
+
const rawBody = await event.requestDigest.getBody();
|
|
187
|
+
if (rawBody?.type === 'buffer' && rawBody.buffer) {
|
|
188
|
+
const jsonStr = rawBody.buffer.toString('utf8');
|
|
189
|
+
body = JSON.parse(jsonStr);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
log.debug('[auth-cookie] Failed to parse body from requestDigest');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
body = body || req.body;
|
|
197
|
+
if (!body?.query) {
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
// Extract mutation names
|
|
201
|
+
const mutationNames = extractMutationNames(body.query);
|
|
202
|
+
if (mutationNames.length === 0) {
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
// Check for auth mutations
|
|
206
|
+
const signInMutation = mutationNames.find((m) => SIGN_IN_MUTATIONS.has(m));
|
|
207
|
+
const signOutMutation = mutationNames.find((m) => SIGN_OUT_MUTATIONS.has(m));
|
|
208
|
+
if (!signInMutation && !signOutMutation) {
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
log.debug(`[auth-cookie] Detected auth mutation: ${signInMutation || signOutMutation}`);
|
|
212
|
+
try {
|
|
213
|
+
// Parse response body
|
|
214
|
+
const payload = bufferResult.buffer.toString('utf8');
|
|
215
|
+
const graphqlResponse = JSON.parse(payload);
|
|
216
|
+
// Skip if there are GraphQL errors
|
|
217
|
+
if (graphqlResponse.errors?.length || !graphqlResponse.data) {
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
const data = graphqlResponse.data;
|
|
221
|
+
const authSettings = req.api?.authSettings;
|
|
222
|
+
const cookiesToSet = [];
|
|
223
|
+
// Handle sign-out mutations
|
|
224
|
+
if (signOutMutation && data[signOutMutation]) {
|
|
225
|
+
log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie');
|
|
226
|
+
const config = getSessionCookieConfig(authSettings);
|
|
227
|
+
cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config));
|
|
228
|
+
// Also clear device token on sign-out
|
|
229
|
+
const deviceConfig = getDeviceTokenCookieConfig(authSettings);
|
|
230
|
+
cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
|
|
231
|
+
}
|
|
232
|
+
// Handle sign-in mutations
|
|
233
|
+
if (signInMutation) {
|
|
234
|
+
const accessToken = extractAccessToken(data, signInMutation);
|
|
235
|
+
if (accessToken) {
|
|
236
|
+
const rememberMe = hasRememberMe(body.variables);
|
|
237
|
+
const config = getSessionCookieConfig(authSettings, rememberMe);
|
|
238
|
+
log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`);
|
|
239
|
+
cookiesToSet.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, config));
|
|
240
|
+
const deviceId = extractDeviceId(data, signInMutation);
|
|
241
|
+
if (deviceId) {
|
|
242
|
+
log.info('[auth-cookie] Device ID returned, setting device token cookie');
|
|
243
|
+
const deviceConfig = getDeviceTokenCookieConfig(authSettings);
|
|
244
|
+
cookiesToSet.push(serializeCookie(DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Set cookies directly on Express response and return modified headers
|
|
249
|
+
if (cookiesToSet.length > 0) {
|
|
250
|
+
const res = event.requestDigest.requestContext?.expressv4?.res;
|
|
251
|
+
if (res?.setHeader) {
|
|
252
|
+
// Get existing Set-Cookie headers from Express response
|
|
253
|
+
const existingCookies = res.getHeader('Set-Cookie');
|
|
254
|
+
const allCookies = [];
|
|
255
|
+
if (existingCookies) {
|
|
256
|
+
if (Array.isArray(existingCookies)) {
|
|
257
|
+
allCookies.push(...existingCookies);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
allCookies.push(existingCookies);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
allCookies.push(...cookiesToSet);
|
|
264
|
+
// Set as array to get multiple Set-Cookie headers
|
|
265
|
+
res.setHeader('Set-Cookie', allCookies);
|
|
266
|
+
}
|
|
267
|
+
// Also update the BufferResult headers for grafserv to pass through
|
|
268
|
+
const existingBufferCookie = bufferResult.headers['set-cookie'];
|
|
269
|
+
const updatedHeaders = { ...bufferResult.headers };
|
|
270
|
+
// Remove set-cookie from grafserv headers since we set it on Express
|
|
271
|
+
delete updatedHeaders['set-cookie'];
|
|
272
|
+
return {
|
|
273
|
+
...bufferResult,
|
|
274
|
+
headers: updatedHeaders,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
log.error('[auth-cookie] Error processing auth response:', err);
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
};
|
package/esm/server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createCsrfMiddleware } from '@constructive-io/csrf';
|
|
1
2
|
import { getEnvOptions } from '@constructive-io/graphql-env';
|
|
2
3
|
import { Logger } from '@pgpmjs/logger';
|
|
3
4
|
import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils';
|
|
4
5
|
import { middleware as parseDomains } from '@constructive-io/url-domains';
|
|
6
|
+
import cookieParser from 'cookie-parser';
|
|
5
7
|
import express from 'express';
|
|
6
8
|
import graphqlUpload from 'graphql-upload';
|
|
7
9
|
import { graphileCache, closeAllCaches } from 'graphile-cache';
|
|
@@ -21,7 +23,9 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
|
|
|
21
23
|
import { debugMemory } from './middleware/observability/debug-memory';
|
|
22
24
|
import { localObservabilityOnly } from './middleware/observability/guard';
|
|
23
25
|
import { createRequestLogger } from './middleware/observability/request-logger';
|
|
26
|
+
// Auth cookie handling is done via AuthCookiePlugin in grafserv
|
|
24
27
|
import { createCaptchaMiddleware } from './middleware/captcha';
|
|
28
|
+
import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
25
29
|
import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
|
|
26
30
|
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
27
31
|
const log = new Logger('server');
|
|
@@ -120,6 +124,7 @@ class Server {
|
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
app.use(poweredBy('constructive'));
|
|
127
|
+
app.use(cookieParser());
|
|
123
128
|
app.use(cors(fallbackOrigin));
|
|
124
129
|
app.use('/graphql', graphqlUpload.graphqlUploadExpress({
|
|
125
130
|
maxFileSize: 10 * 1024 * 1024, // 10 MB
|
|
@@ -134,6 +139,34 @@ class Server {
|
|
|
134
139
|
app.post('/upload', uploadAuthenticate, ...uploadRoute);
|
|
135
140
|
app.use(authenticate);
|
|
136
141
|
app.use(createCaptchaMiddleware());
|
|
142
|
+
// CSRF protection for cookie-authenticated requests
|
|
143
|
+
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|
|
144
|
+
const csrf = createCsrfMiddleware({
|
|
145
|
+
cookieOptions: {
|
|
146
|
+
httpOnly: false, // SPA clients need to read this via document.cookie
|
|
147
|
+
secure: process.env.NODE_ENV === 'production',
|
|
148
|
+
sameSite: 'lax',
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const csrfProtect = (req, res, next) => {
|
|
152
|
+
// Skip CSRF for Bearer token auth
|
|
153
|
+
const auth = req.headers.authorization;
|
|
154
|
+
if (auth?.toLowerCase().startsWith('bearer ')) {
|
|
155
|
+
return next();
|
|
156
|
+
}
|
|
157
|
+
// Skip if no session cookie (anonymous requests)
|
|
158
|
+
const sessionCookie = parseCookieValue(req, SESSION_COOKIE_NAME);
|
|
159
|
+
if (!sessionCookie) {
|
|
160
|
+
return next();
|
|
161
|
+
}
|
|
162
|
+
// Apply CSRF protection for cookie-authenticated requests
|
|
163
|
+
csrf.protect(req, res, next);
|
|
164
|
+
};
|
|
165
|
+
const csrfSetToken = (req, res, next) => {
|
|
166
|
+
csrf.setToken(req, res, next);
|
|
167
|
+
};
|
|
168
|
+
app.use(csrfSetToken); // Set CSRF token cookie on all requests
|
|
169
|
+
app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
|
|
137
170
|
app.use(graphile(effectiveOpts));
|
|
138
171
|
app.use(flush);
|
|
139
172
|
// Error handling - MUST be LAST
|
package/middleware/api.js
CHANGED
|
@@ -126,6 +126,7 @@ const AUTH_SETTINGS_SQL = (schemaName, tableName) => `
|
|
|
126
126
|
cookie_httponly,
|
|
127
127
|
cookie_max_age,
|
|
128
128
|
cookie_path,
|
|
129
|
+
remember_me_duration,
|
|
129
130
|
enable_captcha,
|
|
130
131
|
captcha_site_key
|
|
131
132
|
FROM "${schemaName}"."${tableName}"
|
|
@@ -204,6 +205,7 @@ const DATABASE_SETTINGS_SQL = `
|
|
|
204
205
|
ds.enable_connection_filter,
|
|
205
206
|
ds.enable_ltree,
|
|
206
207
|
ds.enable_llm,
|
|
208
|
+
ds.enable_bulk,
|
|
207
209
|
COALESCE(aps.enable_aggregates, ds.enable_aggregates) AS resolved_enable_aggregates,
|
|
208
210
|
COALESCE(aps.enable_postgis, ds.enable_postgis) AS resolved_enable_postgis,
|
|
209
211
|
COALESCE(aps.enable_search, ds.enable_search) AS resolved_enable_search,
|
|
@@ -213,7 +215,8 @@ const DATABASE_SETTINGS_SQL = `
|
|
|
213
215
|
COALESCE(aps.enable_connection_filter, ds.enable_connection_filter) AS resolved_enable_connection_filter,
|
|
214
216
|
COALESCE(aps.enable_ltree, ds.enable_ltree) AS resolved_enable_ltree,
|
|
215
217
|
COALESCE(aps.enable_llm, ds.enable_llm) AS resolved_enable_llm,
|
|
216
|
-
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime
|
|
218
|
+
COALESCE(aps.enable_realtime, ds.enable_realtime) AS resolved_enable_realtime,
|
|
219
|
+
COALESCE(aps.enable_bulk, ds.enable_bulk) AS resolved_enable_bulk
|
|
217
220
|
FROM services_public.database_settings ds
|
|
218
221
|
LEFT JOIN services_public.api_settings aps ON ds.database_id = aps.database_id AND aps.api_id = $2
|
|
219
222
|
WHERE ds.database_id = $1
|
|
@@ -306,6 +309,7 @@ const toAuthSettings = (row) => {
|
|
|
306
309
|
cookieHttponly: row.cookie_httponly,
|
|
307
310
|
cookieMaxAge: row.cookie_max_age,
|
|
308
311
|
cookiePath: row.cookie_path,
|
|
312
|
+
rememberMeDuration: row.remember_me_duration,
|
|
309
313
|
enableCaptcha: row.enable_captcha,
|
|
310
314
|
captchaSiteKey: row.captcha_site_key,
|
|
311
315
|
};
|
|
@@ -495,6 +499,7 @@ const toDatabaseSettings = (row) => {
|
|
|
495
499
|
enableLtree: row.resolved_enable_ltree,
|
|
496
500
|
enableLlm: row.resolved_enable_llm,
|
|
497
501
|
enableRealtime: row.resolved_enable_realtime,
|
|
502
|
+
enableBulk: row.resolved_enable_bulk,
|
|
498
503
|
};
|
|
499
504
|
};
|
|
500
505
|
const queryDatabaseSettings = async (pool, databaseId, apiId) => {
|
package/middleware/auth.js
CHANGED
|
@@ -13,6 +13,8 @@ const log = new logger_1.Logger('auth');
|
|
|
13
13
|
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
14
14
|
/** Default cookie name for session tokens. */
|
|
15
15
|
const SESSION_COOKIE_NAME = 'constructive_session';
|
|
16
|
+
/** Cookie name for trusted device tracking. */
|
|
17
|
+
const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
|
|
16
18
|
/**
|
|
17
19
|
* Extract a named cookie value from the raw Cookie header.
|
|
18
20
|
* Avoids pulling in cookie-parser as a dependency.
|
|
@@ -116,6 +118,12 @@ const createAuthenticateMiddleware = (opts) => {
|
|
|
116
118
|
log.info(`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
|
|
117
119
|
`privateSchema=${rlsModule.privateSchema?.schemaName ?? 'none'}`);
|
|
118
120
|
}
|
|
121
|
+
// Read device token cookie for trusted device tracking
|
|
122
|
+
const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
|
|
123
|
+
if (deviceToken) {
|
|
124
|
+
req.deviceToken = deviceToken;
|
|
125
|
+
log.info('[auth] Device token cookie present');
|
|
126
|
+
}
|
|
119
127
|
next();
|
|
120
128
|
};
|
|
121
129
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { AuthSettings } from '../types';
|
|
3
|
+
export declare const SESSION_COOKIE_NAME = "constructive_session";
|
|
4
|
+
export declare const DEVICE_TOKEN_COOKIE_NAME = "constructive_device_token";
|
|
5
|
+
export interface CookieConfig {
|
|
6
|
+
secure: boolean;
|
|
7
|
+
sameSite: 'strict' | 'lax' | 'none';
|
|
8
|
+
domain?: string;
|
|
9
|
+
httpOnly: boolean;
|
|
10
|
+
maxAge: number;
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build cookie config from AuthSettings with optional remember_me override.
|
|
15
|
+
*/
|
|
16
|
+
export declare const getSessionCookieConfig: (authSettings?: AuthSettings, rememberMe?: boolean) => CookieConfig;
|
|
17
|
+
/**
|
|
18
|
+
* Build cookie config for device token (long-lived, 90 days).
|
|
19
|
+
*/
|
|
20
|
+
export declare const getDeviceTokenCookieConfig: (authSettings?: AuthSettings) => CookieConfig;
|
|
21
|
+
/**
|
|
22
|
+
* Set the session cookie with the access token.
|
|
23
|
+
*/
|
|
24
|
+
export declare const setSessionCookie: (res: Response, accessToken: string, config: CookieConfig) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Clear the session cookie.
|
|
27
|
+
*/
|
|
28
|
+
export declare const clearSessionCookie: (res: Response, config: CookieConfig) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Set the device token cookie (long-lived for trusted device tracking).
|
|
31
|
+
*/
|
|
32
|
+
export declare const setDeviceTokenCookie: (res: Response, deviceToken: string, config: CookieConfig) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Clear the device token cookie.
|
|
35
|
+
*/
|
|
36
|
+
export declare const clearDeviceTokenCookie: (res: Response, config: CookieConfig) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Parse a cookie value from the raw Cookie header.
|
|
39
|
+
* Avoids pulling in cookie-parser as a dependency.
|
|
40
|
+
*/
|
|
41
|
+
export declare const parseCookieValue: (req: Request, cookieName: string) => string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Get the device token from the request cookie.
|
|
44
|
+
*/
|
|
45
|
+
export declare const getDeviceTokenFromRequest: (req: Request) => string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Get the session token from the request cookie.
|
|
48
|
+
*/
|
|
49
|
+
export declare const getSessionTokenFromRequest: (req: Request) => string | undefined;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getSessionTokenFromRequest = exports.getDeviceTokenFromRequest = exports.parseCookieValue = exports.clearDeviceTokenCookie = exports.setDeviceTokenCookie = exports.clearSessionCookie = exports.setSessionCookie = exports.getDeviceTokenCookieConfig = exports.getSessionCookieConfig = exports.DEVICE_TOKEN_COOKIE_NAME = exports.SESSION_COOKIE_NAME = void 0;
|
|
4
|
+
exports.SESSION_COOKIE_NAME = 'constructive_session';
|
|
5
|
+
exports.DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
|
|
6
|
+
const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
7
|
+
/**
|
|
8
|
+
* Build cookie config from AuthSettings with optional remember_me override.
|
|
9
|
+
*/
|
|
10
|
+
const getSessionCookieConfig = (authSettings, rememberMe = false) => {
|
|
11
|
+
const DEFAULT_MAX_AGE = 86400; // 24 hours
|
|
12
|
+
let maxAge = DEFAULT_MAX_AGE;
|
|
13
|
+
if (rememberMe && authSettings?.rememberMeDuration) {
|
|
14
|
+
const parsed = parseInt(authSettings.rememberMeDuration, 10);
|
|
15
|
+
if (!isNaN(parsed))
|
|
16
|
+
maxAge = parsed;
|
|
17
|
+
}
|
|
18
|
+
else if (authSettings?.cookieMaxAge) {
|
|
19
|
+
const parsed = parseInt(authSettings.cookieMaxAge, 10);
|
|
20
|
+
if (!isNaN(parsed))
|
|
21
|
+
maxAge = parsed;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
|
|
25
|
+
sameSite: authSettings?.cookieSamesite ?? 'lax',
|
|
26
|
+
domain: authSettings?.cookieDomain ?? undefined,
|
|
27
|
+
httpOnly: authSettings?.cookieHttponly ?? true,
|
|
28
|
+
maxAge,
|
|
29
|
+
path: authSettings?.cookiePath ?? '/',
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
exports.getSessionCookieConfig = getSessionCookieConfig;
|
|
33
|
+
/**
|
|
34
|
+
* Build cookie config for device token (long-lived, 90 days).
|
|
35
|
+
*/
|
|
36
|
+
const getDeviceTokenCookieConfig = (authSettings) => {
|
|
37
|
+
return {
|
|
38
|
+
secure: authSettings?.cookieSecure ?? process.env.NODE_ENV === 'production',
|
|
39
|
+
sameSite: authSettings?.cookieSamesite ?? 'lax',
|
|
40
|
+
domain: authSettings?.cookieDomain ?? undefined,
|
|
41
|
+
httpOnly: true,
|
|
42
|
+
maxAge: DEVICE_TOKEN_MAX_AGE,
|
|
43
|
+
path: authSettings?.cookiePath ?? '/',
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
exports.getDeviceTokenCookieConfig = getDeviceTokenCookieConfig;
|
|
47
|
+
/**
|
|
48
|
+
* Set the session cookie with the access token.
|
|
49
|
+
*/
|
|
50
|
+
const setSessionCookie = (res, accessToken, config) => {
|
|
51
|
+
res.cookie(exports.SESSION_COOKIE_NAME, accessToken, {
|
|
52
|
+
secure: config.secure,
|
|
53
|
+
sameSite: config.sameSite,
|
|
54
|
+
domain: config.domain,
|
|
55
|
+
httpOnly: config.httpOnly,
|
|
56
|
+
maxAge: config.maxAge * 1000, // Express expects milliseconds
|
|
57
|
+
path: config.path,
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
exports.setSessionCookie = setSessionCookie;
|
|
61
|
+
/**
|
|
62
|
+
* Clear the session cookie.
|
|
63
|
+
*/
|
|
64
|
+
const clearSessionCookie = (res, config) => {
|
|
65
|
+
res.clearCookie(exports.SESSION_COOKIE_NAME, {
|
|
66
|
+
secure: config.secure,
|
|
67
|
+
sameSite: config.sameSite,
|
|
68
|
+
domain: config.domain,
|
|
69
|
+
httpOnly: config.httpOnly,
|
|
70
|
+
path: config.path,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
exports.clearSessionCookie = clearSessionCookie;
|
|
74
|
+
/**
|
|
75
|
+
* Set the device token cookie (long-lived for trusted device tracking).
|
|
76
|
+
*/
|
|
77
|
+
const setDeviceTokenCookie = (res, deviceToken, config) => {
|
|
78
|
+
res.cookie(exports.DEVICE_TOKEN_COOKIE_NAME, deviceToken, {
|
|
79
|
+
secure: config.secure,
|
|
80
|
+
sameSite: config.sameSite,
|
|
81
|
+
domain: config.domain,
|
|
82
|
+
httpOnly: config.httpOnly,
|
|
83
|
+
maxAge: config.maxAge * 1000,
|
|
84
|
+
path: config.path,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
exports.setDeviceTokenCookie = setDeviceTokenCookie;
|
|
88
|
+
/**
|
|
89
|
+
* Clear the device token cookie.
|
|
90
|
+
*/
|
|
91
|
+
const clearDeviceTokenCookie = (res, config) => {
|
|
92
|
+
res.clearCookie(exports.DEVICE_TOKEN_COOKIE_NAME, {
|
|
93
|
+
secure: config.secure,
|
|
94
|
+
sameSite: config.sameSite,
|
|
95
|
+
domain: config.domain,
|
|
96
|
+
httpOnly: config.httpOnly,
|
|
97
|
+
path: config.path,
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
exports.clearDeviceTokenCookie = clearDeviceTokenCookie;
|
|
101
|
+
/**
|
|
102
|
+
* Parse a cookie value from the raw Cookie header.
|
|
103
|
+
* Avoids pulling in cookie-parser as a dependency.
|
|
104
|
+
*/
|
|
105
|
+
const parseCookieValue = (req, cookieName) => {
|
|
106
|
+
const header = req.headers.cookie;
|
|
107
|
+
if (!header)
|
|
108
|
+
return undefined;
|
|
109
|
+
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
|
|
110
|
+
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
|
|
111
|
+
};
|
|
112
|
+
exports.parseCookieValue = parseCookieValue;
|
|
113
|
+
/**
|
|
114
|
+
* Get the device token from the request cookie.
|
|
115
|
+
*/
|
|
116
|
+
const getDeviceTokenFromRequest = (req) => {
|
|
117
|
+
return (0, exports.parseCookieValue)(req, exports.DEVICE_TOKEN_COOKIE_NAME);
|
|
118
|
+
};
|
|
119
|
+
exports.getDeviceTokenFromRequest = getDeviceTokenFromRequest;
|
|
120
|
+
/**
|
|
121
|
+
* Get the session token from the request cookie.
|
|
122
|
+
*/
|
|
123
|
+
const getSessionTokenFromRequest = (req) => {
|
|
124
|
+
return (0, exports.parseCookieValue)(req, exports.SESSION_COOKIE_NAME);
|
|
125
|
+
};
|
|
126
|
+
exports.getSessionTokenFromRequest = getSessionTokenFromRequest;
|
|
@@ -29,6 +29,10 @@ const sanitizeMessage = (error) => {
|
|
|
29
29
|
return 'The requested resource does not exist';
|
|
30
30
|
return 'An unexpected error occurred';
|
|
31
31
|
};
|
|
32
|
+
const isCsrfError = (err) => {
|
|
33
|
+
const code = err.code;
|
|
34
|
+
return typeof code === 'string' && code.startsWith('CSRF_');
|
|
35
|
+
};
|
|
32
36
|
const categorizeError = (err) => {
|
|
33
37
|
if ((0, api_errors_1.isApiError)(err)) {
|
|
34
38
|
return {
|
|
@@ -38,6 +42,10 @@ const categorizeError = (err) => {
|
|
|
38
42
|
logLevel: err.statusCode >= 500 ? 'error' : 'warn',
|
|
39
43
|
};
|
|
40
44
|
}
|
|
45
|
+
if (isCsrfError(err)) {
|
|
46
|
+
const code = err.code;
|
|
47
|
+
return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
|
|
48
|
+
}
|
|
41
49
|
if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
|
|
42
50
|
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
|
|
43
51
|
}
|
package/middleware/graphile.js
CHANGED
|
@@ -18,6 +18,7 @@ require("./types"); // for Request type
|
|
|
18
18
|
const observability_1 = require("../diagnostics/observability");
|
|
19
19
|
const api_errors_1 = require("../errors/api-errors");
|
|
20
20
|
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
21
|
+
const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
|
|
21
22
|
const maskErrorLog = new logger_1.Logger('graphile:maskError');
|
|
22
23
|
const SAFE_ERROR_CODES = new Set([
|
|
23
24
|
// GraphQL standard
|
|
@@ -195,6 +196,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
195
196
|
const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
196
197
|
return {
|
|
197
198
|
extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
|
|
199
|
+
plugins: [auth_cookie_plugin_1.AuthCookiePlugin],
|
|
198
200
|
pgServices: [
|
|
199
201
|
(0, graphile_settings_1.makePgService)({
|
|
200
202
|
pool,
|
|
@@ -227,6 +229,9 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
|
|
|
227
229
|
if (req.get('User-Agent')) {
|
|
228
230
|
context['jwt.claims.user_agent'] = req.get('User-Agent');
|
|
229
231
|
}
|
|
232
|
+
if (req.deviceToken) {
|
|
233
|
+
context['jwt.claims.device_token'] = req.deviceToken;
|
|
234
|
+
}
|
|
230
235
|
if (req.token?.user_id) {
|
|
231
236
|
const pgSettings = {
|
|
232
237
|
role: roleName,
|
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.30.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"backend"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@constructive-io/csrf": "^0.13.1",
|
|
44
45
|
"@constructive-io/graphql-env": "^3.10.1",
|
|
45
46
|
"@constructive-io/graphql-types": "^3.9.1",
|
|
46
47
|
"@constructive-io/s3-utils": "^2.16.1",
|
|
@@ -63,7 +64,7 @@
|
|
|
63
64
|
"graphile-build-pg": "5.0.0",
|
|
64
65
|
"graphile-cache": "^3.10.1",
|
|
65
66
|
"graphile-config": "1.0.0",
|
|
66
|
-
"graphile-settings": "^
|
|
67
|
+
"graphile-settings": "^5.0.0",
|
|
67
68
|
"graphile-utils": "5.0.0",
|
|
68
69
|
"graphql": "16.13.0",
|
|
69
70
|
"graphql-upload": "^13.0.0",
|
|
@@ -79,16 +80,18 @@
|
|
|
79
80
|
},
|
|
80
81
|
"devDependencies": {
|
|
81
82
|
"@aws-sdk/client-s3": "^3.1009.0",
|
|
83
|
+
"@types/cookie-parser": "^1.4.10",
|
|
82
84
|
"@types/cors": "^2.8.17",
|
|
83
85
|
"@types/express": "^5.0.6",
|
|
84
86
|
"@types/graphql-upload": "^8.0.12",
|
|
85
87
|
"@types/multer": "^2.1.0",
|
|
86
88
|
"@types/pg": "^8.18.0",
|
|
87
89
|
"@types/request-ip": "^0.0.41",
|
|
88
|
-
"
|
|
90
|
+
"cookie-parser": "^1.4.7",
|
|
91
|
+
"graphile-test": "4.14.2",
|
|
89
92
|
"makage": "^0.3.0",
|
|
90
93
|
"nodemon": "^3.1.14",
|
|
91
94
|
"ts-node": "^10.9.2"
|
|
92
95
|
},
|
|
93
|
-
"gitHead": "
|
|
96
|
+
"gitHead": "28b0b236e65b2a2228acad4fd840543c04b24825"
|
|
94
97
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
2
|
+
import '../middleware/types';
|
|
3
|
+
/**
|
|
4
|
+
* AuthCookiePlugin - grafserv middleware plugin that handles auth cookie lifecycle.
|
|
5
|
+
*
|
|
6
|
+
* This plugin intercepts GraphQL responses and:
|
|
7
|
+
* - Sets session cookies on successful sign-in mutations
|
|
8
|
+
* - Clears session cookies on sign-out mutations
|
|
9
|
+
* - Handles device token cookies for trusted device tracking
|
|
10
|
+
*/
|
|
11
|
+
export declare const AuthCookiePlugin: GraphileConfig.Plugin;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuthCookiePlugin = void 0;
|
|
4
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
5
|
+
require("../middleware/types");
|
|
6
|
+
const cookie_1 = require("../middleware/cookie");
|
|
7
|
+
const log = new logger_1.Logger('auth-cookie');
|
|
8
|
+
/**
|
|
9
|
+
* Serialize a cookie to a Set-Cookie header value.
|
|
10
|
+
*/
|
|
11
|
+
const serializeCookie = (name, value, config) => {
|
|
12
|
+
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
13
|
+
if (config.maxAge !== undefined) {
|
|
14
|
+
parts.push(`Max-Age=${config.maxAge}`);
|
|
15
|
+
}
|
|
16
|
+
if (config.domain) {
|
|
17
|
+
parts.push(`Domain=${config.domain}`);
|
|
18
|
+
}
|
|
19
|
+
if (config.path) {
|
|
20
|
+
parts.push(`Path=${config.path}`);
|
|
21
|
+
}
|
|
22
|
+
if (config.secure) {
|
|
23
|
+
parts.push('Secure');
|
|
24
|
+
}
|
|
25
|
+
if (config.httpOnly) {
|
|
26
|
+
parts.push('HttpOnly');
|
|
27
|
+
}
|
|
28
|
+
if (config.sameSite) {
|
|
29
|
+
parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
|
|
30
|
+
}
|
|
31
|
+
return parts.join('; ');
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Serialize a cookie for clearing (expired).
|
|
35
|
+
*/
|
|
36
|
+
const serializeClearCookie = (name, config) => {
|
|
37
|
+
const parts = [`${encodeURIComponent(name)}=`];
|
|
38
|
+
parts.push('Max-Age=0');
|
|
39
|
+
if (config.domain) {
|
|
40
|
+
parts.push(`Domain=${config.domain}`);
|
|
41
|
+
}
|
|
42
|
+
if (config.path) {
|
|
43
|
+
parts.push(`Path=${config.path}`);
|
|
44
|
+
}
|
|
45
|
+
if (config.secure) {
|
|
46
|
+
parts.push('Secure');
|
|
47
|
+
}
|
|
48
|
+
if (config.httpOnly) {
|
|
49
|
+
parts.push('HttpOnly');
|
|
50
|
+
}
|
|
51
|
+
if (config.sameSite) {
|
|
52
|
+
parts.push(`SameSite=${config.sameSite.charAt(0).toUpperCase() + config.sameSite.slice(1)}`);
|
|
53
|
+
}
|
|
54
|
+
return parts.join('; ');
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Auth mutations that should set session cookie on success.
|
|
58
|
+
*/
|
|
59
|
+
const SIGN_IN_MUTATIONS = new Set([
|
|
60
|
+
'signIn',
|
|
61
|
+
'signUp',
|
|
62
|
+
'signInSso',
|
|
63
|
+
'signUpSso',
|
|
64
|
+
'signInMagicLink',
|
|
65
|
+
'signUpMagicLink',
|
|
66
|
+
'signInEmailOtp',
|
|
67
|
+
'signInSmsOtp',
|
|
68
|
+
'signUpSms',
|
|
69
|
+
'completeMfaChallenge',
|
|
70
|
+
'signInOneTimeToken',
|
|
71
|
+
'signInCrossOrigin',
|
|
72
|
+
]);
|
|
73
|
+
/**
|
|
74
|
+
* Auth mutations that should clear the session cookie.
|
|
75
|
+
*/
|
|
76
|
+
const SIGN_OUT_MUTATIONS = new Set([
|
|
77
|
+
'signOut',
|
|
78
|
+
'revokeSession',
|
|
79
|
+
'revokeAllSessions',
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Extract mutation names from a GraphQL query string.
|
|
83
|
+
*/
|
|
84
|
+
const extractMutationNames = (query) => {
|
|
85
|
+
const mutations = [];
|
|
86
|
+
if (!/^\s*mutation\b/i.test(query)) {
|
|
87
|
+
return mutations;
|
|
88
|
+
}
|
|
89
|
+
const bodyStart = query.indexOf('{');
|
|
90
|
+
if (bodyStart === -1)
|
|
91
|
+
return mutations;
|
|
92
|
+
const bodyContent = query.slice(bodyStart + 1);
|
|
93
|
+
const fieldPattern = /(\w+)\s*(?:\(|{)/g;
|
|
94
|
+
let match;
|
|
95
|
+
while ((match = fieldPattern.exec(bodyContent)) !== null) {
|
|
96
|
+
const name = match[1];
|
|
97
|
+
if (name !== 'mutation' && name !== 'query' && name !== 'fragment') {
|
|
98
|
+
mutations.push(name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return mutations;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Extract access token from mutation response.
|
|
105
|
+
*/
|
|
106
|
+
const extractAccessToken = (data, mutationName) => {
|
|
107
|
+
const result = data[mutationName];
|
|
108
|
+
if (!result)
|
|
109
|
+
return undefined;
|
|
110
|
+
// Check for non-empty string tokens
|
|
111
|
+
if (typeof result.accessToken === 'string' && result.accessToken)
|
|
112
|
+
return result.accessToken;
|
|
113
|
+
if (typeof result.access_token === 'string' && result.access_token)
|
|
114
|
+
return result.access_token;
|
|
115
|
+
const nested = result.result;
|
|
116
|
+
if (nested) {
|
|
117
|
+
if (typeof nested.accessToken === 'string' && nested.accessToken)
|
|
118
|
+
return nested.accessToken;
|
|
119
|
+
if (typeof nested.access_token === 'string' && nested.access_token)
|
|
120
|
+
return nested.access_token;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Extract device ID from mutation response.
|
|
126
|
+
*/
|
|
127
|
+
const extractDeviceId = (data, mutationName) => {
|
|
128
|
+
const result = data[mutationName];
|
|
129
|
+
if (!result)
|
|
130
|
+
return undefined;
|
|
131
|
+
if (typeof result.deviceId === 'string')
|
|
132
|
+
return result.deviceId;
|
|
133
|
+
if (typeof result.device_id === 'string')
|
|
134
|
+
return result.device_id;
|
|
135
|
+
const nested = result.result;
|
|
136
|
+
if (nested) {
|
|
137
|
+
if (typeof nested.deviceId === 'string')
|
|
138
|
+
return nested.deviceId;
|
|
139
|
+
if (typeof nested.device_id === 'string')
|
|
140
|
+
return nested.device_id;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Check if request includes remember_me flag.
|
|
146
|
+
*/
|
|
147
|
+
const hasRememberMe = (variables) => {
|
|
148
|
+
if (!variables)
|
|
149
|
+
return false;
|
|
150
|
+
return variables.rememberMe === true || variables.remember_me === true;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Get Express request from grafserv request context.
|
|
154
|
+
*/
|
|
155
|
+
const getExpressRequest = (requestContext) => {
|
|
156
|
+
return requestContext?.expressv4?.req;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* AuthCookiePlugin - grafserv middleware plugin that handles auth cookie lifecycle.
|
|
160
|
+
*
|
|
161
|
+
* This plugin intercepts GraphQL responses and:
|
|
162
|
+
* - Sets session cookies on successful sign-in mutations
|
|
163
|
+
* - Clears session cookies on sign-out mutations
|
|
164
|
+
* - Handles device token cookies for trusted device tracking
|
|
165
|
+
*/
|
|
166
|
+
exports.AuthCookiePlugin = {
|
|
167
|
+
name: 'AuthCookiePlugin',
|
|
168
|
+
version: '1.0.0',
|
|
169
|
+
grafserv: {
|
|
170
|
+
middleware: {
|
|
171
|
+
processRequest: {
|
|
172
|
+
callback: async (next, event) => {
|
|
173
|
+
const result = await next();
|
|
174
|
+
// Only process buffer results (JSON responses)
|
|
175
|
+
if (!result || result.type !== 'buffer') {
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
const bufferResult = result;
|
|
179
|
+
const req = getExpressRequest(event.requestDigest.requestContext);
|
|
180
|
+
// Skip if no Express request or not a POST
|
|
181
|
+
if (!req || event.requestDigest.method !== 'POST') {
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
// Get request body for mutation detection
|
|
185
|
+
// grafserv provides getBody() which returns { type: 'buffer', buffer: Buffer }
|
|
186
|
+
let body;
|
|
187
|
+
if (typeof event.requestDigest.getBody === 'function') {
|
|
188
|
+
try {
|
|
189
|
+
const rawBody = await event.requestDigest.getBody();
|
|
190
|
+
if (rawBody?.type === 'buffer' && rawBody.buffer) {
|
|
191
|
+
const jsonStr = rawBody.buffer.toString('utf8');
|
|
192
|
+
body = JSON.parse(jsonStr);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
log.debug('[auth-cookie] Failed to parse body from requestDigest');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
body = body || req.body;
|
|
200
|
+
if (!body?.query) {
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
// Extract mutation names
|
|
204
|
+
const mutationNames = extractMutationNames(body.query);
|
|
205
|
+
if (mutationNames.length === 0) {
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
// Check for auth mutations
|
|
209
|
+
const signInMutation = mutationNames.find((m) => SIGN_IN_MUTATIONS.has(m));
|
|
210
|
+
const signOutMutation = mutationNames.find((m) => SIGN_OUT_MUTATIONS.has(m));
|
|
211
|
+
if (!signInMutation && !signOutMutation) {
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
log.debug(`[auth-cookie] Detected auth mutation: ${signInMutation || signOutMutation}`);
|
|
215
|
+
try {
|
|
216
|
+
// Parse response body
|
|
217
|
+
const payload = bufferResult.buffer.toString('utf8');
|
|
218
|
+
const graphqlResponse = JSON.parse(payload);
|
|
219
|
+
// Skip if there are GraphQL errors
|
|
220
|
+
if (graphqlResponse.errors?.length || !graphqlResponse.data) {
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
const data = graphqlResponse.data;
|
|
224
|
+
const authSettings = req.api?.authSettings;
|
|
225
|
+
const cookiesToSet = [];
|
|
226
|
+
// Handle sign-out mutations
|
|
227
|
+
if (signOutMutation && data[signOutMutation]) {
|
|
228
|
+
log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie');
|
|
229
|
+
const config = (0, cookie_1.getSessionCookieConfig)(authSettings);
|
|
230
|
+
cookiesToSet.push(serializeClearCookie(cookie_1.SESSION_COOKIE_NAME, config));
|
|
231
|
+
// Also clear device token on sign-out
|
|
232
|
+
const deviceConfig = (0, cookie_1.getDeviceTokenCookieConfig)(authSettings);
|
|
233
|
+
cookiesToSet.push(serializeClearCookie(cookie_1.DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
|
|
234
|
+
}
|
|
235
|
+
// Handle sign-in mutations
|
|
236
|
+
if (signInMutation) {
|
|
237
|
+
const accessToken = extractAccessToken(data, signInMutation);
|
|
238
|
+
if (accessToken) {
|
|
239
|
+
const rememberMe = hasRememberMe(body.variables);
|
|
240
|
+
const config = (0, cookie_1.getSessionCookieConfig)(authSettings, rememberMe);
|
|
241
|
+
log.info(`[auth-cookie] Sign-in mutation succeeded, setting session cookie (rememberMe=${rememberMe})`);
|
|
242
|
+
cookiesToSet.push(serializeCookie(cookie_1.SESSION_COOKIE_NAME, accessToken, config));
|
|
243
|
+
const deviceId = extractDeviceId(data, signInMutation);
|
|
244
|
+
if (deviceId) {
|
|
245
|
+
log.info('[auth-cookie] Device ID returned, setting device token cookie');
|
|
246
|
+
const deviceConfig = (0, cookie_1.getDeviceTokenCookieConfig)(authSettings);
|
|
247
|
+
cookiesToSet.push(serializeCookie(cookie_1.DEVICE_TOKEN_COOKIE_NAME, deviceId, deviceConfig));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// Set cookies directly on Express response and return modified headers
|
|
252
|
+
if (cookiesToSet.length > 0) {
|
|
253
|
+
const res = event.requestDigest.requestContext?.expressv4?.res;
|
|
254
|
+
if (res?.setHeader) {
|
|
255
|
+
// Get existing Set-Cookie headers from Express response
|
|
256
|
+
const existingCookies = res.getHeader('Set-Cookie');
|
|
257
|
+
const allCookies = [];
|
|
258
|
+
if (existingCookies) {
|
|
259
|
+
if (Array.isArray(existingCookies)) {
|
|
260
|
+
allCookies.push(...existingCookies);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
allCookies.push(existingCookies);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
allCookies.push(...cookiesToSet);
|
|
267
|
+
// Set as array to get multiple Set-Cookie headers
|
|
268
|
+
res.setHeader('Set-Cookie', allCookies);
|
|
269
|
+
}
|
|
270
|
+
// Also update the BufferResult headers for grafserv to pass through
|
|
271
|
+
const existingBufferCookie = bufferResult.headers['set-cookie'];
|
|
272
|
+
const updatedHeaders = { ...bufferResult.headers };
|
|
273
|
+
// Remove set-cookie from grafserv headers since we set it on Express
|
|
274
|
+
delete updatedHeaders['set-cookie'];
|
|
275
|
+
return {
|
|
276
|
+
...bufferResult,
|
|
277
|
+
headers: updatedHeaders,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
log.error('[auth-cookie] Error processing auth response:', err);
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
package/server.js
CHANGED
|
@@ -4,10 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.Server = exports.GraphQLServer = void 0;
|
|
7
|
+
const csrf_1 = require("@constructive-io/csrf");
|
|
7
8
|
const graphql_env_1 = require("@constructive-io/graphql-env");
|
|
8
9
|
const logger_1 = require("@pgpmjs/logger");
|
|
9
10
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
10
11
|
const url_domains_1 = require("@constructive-io/url-domains");
|
|
12
|
+
const cookie_parser_1 = __importDefault(require("cookie-parser"));
|
|
11
13
|
const express_1 = __importDefault(require("express"));
|
|
12
14
|
const graphql_upload_1 = __importDefault(require("graphql-upload"));
|
|
13
15
|
const graphile_cache_1 = require("graphile-cache");
|
|
@@ -27,7 +29,9 @@ const debug_db_1 = require("./middleware/observability/debug-db");
|
|
|
27
29
|
const debug_memory_1 = require("./middleware/observability/debug-memory");
|
|
28
30
|
const guard_1 = require("./middleware/observability/guard");
|
|
29
31
|
const request_logger_1 = require("./middleware/observability/request-logger");
|
|
32
|
+
// Auth cookie handling is done via AuthCookiePlugin in grafserv
|
|
30
33
|
const captcha_1 = require("./middleware/captcha");
|
|
34
|
+
const cookie_1 = require("./middleware/cookie");
|
|
31
35
|
const upload_1 = require("./middleware/upload");
|
|
32
36
|
const debug_sampler_1 = require("./diagnostics/debug-sampler");
|
|
33
37
|
const log = new logger_1.Logger('server');
|
|
@@ -127,6 +131,7 @@ class Server {
|
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
app.use((0, server_utils_1.poweredBy)('constructive'));
|
|
134
|
+
app.use((0, cookie_parser_1.default)());
|
|
130
135
|
app.use((0, cors_1.cors)(fallbackOrigin));
|
|
131
136
|
app.use('/graphql', graphql_upload_1.default.graphqlUploadExpress({
|
|
132
137
|
maxFileSize: 10 * 1024 * 1024, // 10 MB
|
|
@@ -141,6 +146,34 @@ class Server {
|
|
|
141
146
|
app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
|
|
142
147
|
app.use(authenticate);
|
|
143
148
|
app.use((0, captcha_1.createCaptchaMiddleware)());
|
|
149
|
+
// CSRF protection for cookie-authenticated requests
|
|
150
|
+
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
|
|
151
|
+
const csrf = (0, csrf_1.createCsrfMiddleware)({
|
|
152
|
+
cookieOptions: {
|
|
153
|
+
httpOnly: false, // SPA clients need to read this via document.cookie
|
|
154
|
+
secure: process.env.NODE_ENV === 'production',
|
|
155
|
+
sameSite: 'lax',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const csrfProtect = (req, res, next) => {
|
|
159
|
+
// Skip CSRF for Bearer token auth
|
|
160
|
+
const auth = req.headers.authorization;
|
|
161
|
+
if (auth?.toLowerCase().startsWith('bearer ')) {
|
|
162
|
+
return next();
|
|
163
|
+
}
|
|
164
|
+
// Skip if no session cookie (anonymous requests)
|
|
165
|
+
const sessionCookie = (0, cookie_1.parseCookieValue)(req, cookie_1.SESSION_COOKIE_NAME);
|
|
166
|
+
if (!sessionCookie) {
|
|
167
|
+
return next();
|
|
168
|
+
}
|
|
169
|
+
// Apply CSRF protection for cookie-authenticated requests
|
|
170
|
+
csrf.protect(req, res, next);
|
|
171
|
+
};
|
|
172
|
+
const csrfSetToken = (req, res, next) => {
|
|
173
|
+
csrf.setToken(req, res, next);
|
|
174
|
+
};
|
|
175
|
+
app.use(csrfSetToken); // Set CSRF token cookie on all requests
|
|
176
|
+
app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
|
|
144
177
|
app.use((0, graphile_1.graphile)(effectiveOpts));
|
|
145
178
|
app.use(flush_1.flush);
|
|
146
179
|
// Error handling - MUST be LAST
|
package/types.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface DatabaseSettings {
|
|
|
29
29
|
enableLtree: boolean;
|
|
30
30
|
enableLlm: boolean;
|
|
31
31
|
enableRealtime: boolean;
|
|
32
|
+
enableBulk: boolean;
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
35
|
* Resolved pubkey challenge config from pubkey_settings typed table.
|
|
@@ -96,6 +97,8 @@ export interface AuthSettings {
|
|
|
96
97
|
cookieHttponly?: boolean;
|
|
97
98
|
cookieMaxAge?: string | null;
|
|
98
99
|
cookiePath?: string;
|
|
100
|
+
/** Remember me duration (seconds) for extended session cookies */
|
|
101
|
+
rememberMeDuration?: string | null;
|
|
99
102
|
/** reCAPTCHA / CAPTCHA */
|
|
100
103
|
enableCaptcha?: boolean;
|
|
101
104
|
captchaSiteKey?: string | null;
|