@constructive-io/graphql-server 5.28.7 → 5.28.9
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/README.md +12 -0
- package/errors/api-errors.d.ts +8 -0
- package/errors/api-errors.js +13 -1
- package/esm/errors/api-errors.js +11 -0
- package/esm/middleware/cookie.js +24 -0
- package/esm/middleware/mask-error.js +23 -1
- package/esm/plugins/auth-cookie-plugin.js +26 -29
- package/esm/server.js +4 -1
- package/middleware/cookie.d.ts +18 -1
- package/middleware/cookie.js +27 -1
- package/middleware/mask-error.js +23 -1
- package/package.json +17 -17
- package/plugins/auth-cookie-plugin.js +25 -28
- package/server.js +3 -0
package/README.md
CHANGED
|
@@ -99,6 +99,18 @@ For the operational workflow, sampler output, and heap snapshot usage, see [docs
|
|
|
99
99
|
- `GET /debug/memory` -> memory/process/Graphile debug snapshot when observability is enabled
|
|
100
100
|
- `GET /debug/db` -> PostgreSQL activity/locks/pool debug snapshot when observability is enabled
|
|
101
101
|
|
|
102
|
+
## Auth cookies and CSRF
|
|
103
|
+
|
|
104
|
+
Browsers authenticate with the `constructive_session` cookie; every other client sends `Authorization: Bearer <accessToken>`.
|
|
105
|
+
|
|
106
|
+
- A successful sign-in mutation (`signIn`, `signUp`, `signInSso`, `signInMagicLink`, `signInEmailOtp`, `completeMfaChallenge`, …) returns the access token in the response body and also sets `constructive_session` (plus `constructive_device_token` when the mutation returns a device id). `Max-Age` comes from the API's auth settings (default 86400 s; `rememberMe: true` uses the remember-me duration).
|
|
107
|
+
- `signOut` always answers with expired `constructive_session` and `constructive_device_token` cookies (`Max-Age=0`), however the request authenticated and whether or not the mutation succeeded. `revokeSession` / `revokeAllSessions` clear them on success.
|
|
108
|
+
- Every request gets a `csrf_token` cookie (readable by JS, one day). A `POST /graphql` that carries `constructive_session` and no Bearer header must echo that value in `X-CSRF-Token`, or it is refused with 403 `CSRF_TOKEN_MISSING` / `CSRF_TOKEN_INVALID`. Bearer requests and requests without a session cookie skip the check.
|
|
109
|
+
|
|
110
|
+
### Non-browser clients (native apps, CLIs, servers)
|
|
111
|
+
|
|
112
|
+
Send `X-Constructive-Session-Cookie: omit` on every request. Sign-in mutations then set no auth cookie: read `accessToken` from the response body and send it as `Authorization: Bearer <accessToken>`. Any other value of the header is refused with 400 `INVALID_SESSION_COOKIE_HEADER` before the operation runs. The header does not bypass CSRF: a request that still carries `constructive_session` without a Bearer header is checked like a browser's, so sign out with the Bearer token (its response expires any session cookie the client was given before it opted out).
|
|
113
|
+
|
|
102
114
|
## Scoped routing
|
|
103
115
|
|
|
104
116
|
This is a production-only server: every request is resolved through the scoped-routing plane. There is no static single-tenant mode and no flag to disable routing. For single-database local development without route resolution or a database id, use [`@constructive-io/graphql-dev-server`](../dev-server/README.md).
|
package/errors/api-errors.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare const ErrorCodes: {
|
|
|
22
22
|
readonly DATABASE_CONNECTION_ERROR: "DATABASE_CONNECTION_ERROR";
|
|
23
23
|
readonly AMBIGUOUS_TENANT: "AMBIGUOUS_TENANT";
|
|
24
24
|
readonly ADMIN_AUTH_REQUIRED: "ADMIN_AUTH_REQUIRED";
|
|
25
|
+
readonly INVALID_SESSION_COOKIE_HEADER: "INVALID_SESSION_COOKIE_HEADER";
|
|
25
26
|
};
|
|
26
27
|
/**
|
|
27
28
|
* Type alias for valid error codes.
|
|
@@ -161,6 +162,13 @@ export declare class AmbiguousTenantError extends ApiError {
|
|
|
161
162
|
export declare class AdminAuthRequiredError extends ApiError {
|
|
162
163
|
constructor(reason: string);
|
|
163
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Thrown when the session-cookie opt-out header carries a value other than
|
|
167
|
+
* the one the server accepts, so a typo never silently falls back to cookies.
|
|
168
|
+
*/
|
|
169
|
+
export declare class InvalidSessionCookieHeaderError extends ApiError {
|
|
170
|
+
constructor(header: string, value: string, accepted: string);
|
|
171
|
+
}
|
|
164
172
|
/**
|
|
165
173
|
* Check if an error is an instance of ApiError or any of its subclasses.
|
|
166
174
|
*
|
package/errors/api-errors.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* @module errors/api-errors
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.AdminAuthRequiredError = exports.AmbiguousTenantError = exports.SchemaAccessDeniedError = exports.DatabaseConnectionError = exports.HandlerCreationError = exports.SchemaValidationError = exports.NoValidSchemasError = exports.ApiNotFoundError = exports.DomainNotFoundError = exports.ApiError = exports.ErrorCodes = void 0;
|
|
13
|
+
exports.InvalidSessionCookieHeaderError = exports.AdminAuthRequiredError = exports.AmbiguousTenantError = exports.SchemaAccessDeniedError = exports.DatabaseConnectionError = exports.HandlerCreationError = exports.SchemaValidationError = exports.NoValidSchemasError = exports.ApiNotFoundError = exports.DomainNotFoundError = exports.ApiError = exports.ErrorCodes = void 0;
|
|
14
14
|
exports.isApiError = isApiError;
|
|
15
15
|
exports.hasErrorCode = hasErrorCode;
|
|
16
16
|
// =============================================================================
|
|
@@ -30,6 +30,7 @@ exports.ErrorCodes = {
|
|
|
30
30
|
DATABASE_CONNECTION_ERROR: 'DATABASE_CONNECTION_ERROR',
|
|
31
31
|
AMBIGUOUS_TENANT: 'AMBIGUOUS_TENANT',
|
|
32
32
|
ADMIN_AUTH_REQUIRED: 'ADMIN_AUTH_REQUIRED',
|
|
33
|
+
INVALID_SESSION_COOKIE_HEADER: 'INVALID_SESSION_COOKIE_HEADER',
|
|
33
34
|
};
|
|
34
35
|
// =============================================================================
|
|
35
36
|
// Base Error Class
|
|
@@ -230,6 +231,17 @@ class AdminAuthRequiredError extends ApiError {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
exports.AdminAuthRequiredError = AdminAuthRequiredError;
|
|
234
|
+
/**
|
|
235
|
+
* Thrown when the session-cookie opt-out header carries a value other than
|
|
236
|
+
* the one the server accepts, so a typo never silently falls back to cookies.
|
|
237
|
+
*/
|
|
238
|
+
class InvalidSessionCookieHeaderError extends ApiError {
|
|
239
|
+
constructor(header, value, accepted) {
|
|
240
|
+
super(exports.ErrorCodes.INVALID_SESSION_COOKIE_HEADER, 400, `${header} accepts only "${accepted}"`, { header, value, accepted });
|
|
241
|
+
this.name = 'InvalidSessionCookieHeaderError';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
exports.InvalidSessionCookieHeaderError = InvalidSessionCookieHeaderError;
|
|
233
245
|
// =============================================================================
|
|
234
246
|
// Type Guards
|
|
235
247
|
// =============================================================================
|
package/esm/errors/api-errors.js
CHANGED
|
@@ -25,6 +25,7 @@ export const ErrorCodes = {
|
|
|
25
25
|
DATABASE_CONNECTION_ERROR: 'DATABASE_CONNECTION_ERROR',
|
|
26
26
|
AMBIGUOUS_TENANT: 'AMBIGUOUS_TENANT',
|
|
27
27
|
ADMIN_AUTH_REQUIRED: 'ADMIN_AUTH_REQUIRED',
|
|
28
|
+
INVALID_SESSION_COOKIE_HEADER: 'INVALID_SESSION_COOKIE_HEADER',
|
|
28
29
|
};
|
|
29
30
|
// =============================================================================
|
|
30
31
|
// Base Error Class
|
|
@@ -215,6 +216,16 @@ export class AdminAuthRequiredError extends ApiError {
|
|
|
215
216
|
this.name = 'AdminAuthRequiredError';
|
|
216
217
|
}
|
|
217
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Thrown when the session-cookie opt-out header carries a value other than
|
|
221
|
+
* the one the server accepts, so a typo never silently falls back to cookies.
|
|
222
|
+
*/
|
|
223
|
+
export class InvalidSessionCookieHeaderError extends ApiError {
|
|
224
|
+
constructor(header, value, accepted) {
|
|
225
|
+
super(ErrorCodes.INVALID_SESSION_COOKIE_HEADER, 400, `${header} accepts only "${accepted}"`, { header, value, accepted });
|
|
226
|
+
this.name = 'InvalidSessionCookieHeaderError';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
218
229
|
// =============================================================================
|
|
219
230
|
// Type Guards
|
|
220
231
|
// =============================================================================
|
package/esm/middleware/cookie.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { InvalidSessionCookieHeaderError } from '../errors/api-errors';
|
|
1
2
|
export const SESSION_COOKIE_NAME = 'constructive_session';
|
|
2
3
|
export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
|
|
4
|
+
/**
|
|
5
|
+
* Request header a non-browser client (native app, CLI, server) sends to opt
|
|
6
|
+
* out of auth cookies: sign-in mutations then return the access token in the
|
|
7
|
+
* response body only and set neither the session nor the device-token cookie.
|
|
8
|
+
*/
|
|
9
|
+
export const SESSION_COOKIE_HEADER = 'X-Constructive-Session-Cookie';
|
|
10
|
+
export const SESSION_COOKIE_OMIT = 'omit';
|
|
3
11
|
const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
4
12
|
/**
|
|
5
13
|
* Build cookie config from AuthSettings with optional remember_me override.
|
|
@@ -89,6 +97,22 @@ export const clearDeviceTokenCookie = (res, config) => {
|
|
|
89
97
|
path: config.path,
|
|
90
98
|
});
|
|
91
99
|
};
|
|
100
|
+
/**
|
|
101
|
+
* True when the request opted out of auth cookies via `SESSION_COOKIE_HEADER`.
|
|
102
|
+
*/
|
|
103
|
+
export const isSessionCookieOmitted = (req) => req.get(SESSION_COOKIE_HEADER) === SESSION_COOKIE_OMIT;
|
|
104
|
+
/**
|
|
105
|
+
* Reject a `SESSION_COOKIE_HEADER` with any value but `omit`, before the
|
|
106
|
+
* request executes, so a misspelt opt-out fails loudly instead of setting the
|
|
107
|
+
* cookie the client asked not to receive.
|
|
108
|
+
*/
|
|
109
|
+
export const rejectInvalidSessionCookieHeader = (req, _res, next) => {
|
|
110
|
+
const value = req.get(SESSION_COOKIE_HEADER);
|
|
111
|
+
if (value !== undefined && value !== SESSION_COOKIE_OMIT) {
|
|
112
|
+
return next(new InvalidSessionCookieHeaderError(SESSION_COOKIE_HEADER, value, SESSION_COOKIE_OMIT));
|
|
113
|
+
}
|
|
114
|
+
next();
|
|
115
|
+
};
|
|
92
116
|
/**
|
|
93
117
|
* Parse a cookie value from the raw Cookie header.
|
|
94
118
|
* Avoids pulling in cookie-parser as a dependency.
|
|
@@ -41,6 +41,28 @@ const isGraphQLLayerError = (value) => value == null ||
|
|
|
41
41
|
(value.name === 'GraphQLError' &&
|
|
42
42
|
isGraphQLLayerError(value.originalError));
|
|
43
43
|
const isRequestError = (error) => error.path == null && isGraphQLLayerError(error.originalError);
|
|
44
|
+
/**
|
|
45
|
+
* A request error's message without the values the request sent. graphql-js
|
|
46
|
+
* writes a variable coercion failure as `Variable "$x" got invalid value <the
|
|
47
|
+
* value> at "<path>"; <inner>`, and a leaf type's inner complaint ends in the
|
|
48
|
+
* value too (`String cannot represent a non string value: <value>`,
|
|
49
|
+
* `Value "<value>" does not exist in "E" enum.`), so a password or API key sent
|
|
50
|
+
* in a malformed input would come back in the answer. The variable, the path and
|
|
51
|
+
* what was wrong stay; the values go.
|
|
52
|
+
*/
|
|
53
|
+
const withoutInputValues = (error) => {
|
|
54
|
+
const inner = error.originalError?.message;
|
|
55
|
+
const message = error.message;
|
|
56
|
+
if (!message.startsWith('Variable "$') || !inner || !message.endsWith(`; ${inner}`))
|
|
57
|
+
return message;
|
|
58
|
+
const prefix = message.slice(0, message.length - inner.length - 2);
|
|
59
|
+
const variable = /^Variable "\$[^"]+"/.exec(prefix)?.[0];
|
|
60
|
+
const at = / at "[^"]*"$/.exec(prefix)?.[0] ?? '';
|
|
61
|
+
const complaint = inner
|
|
62
|
+
.replace(/(value): [\s\S]*$/, '$1.')
|
|
63
|
+
.replace(/^Value "[\s\S]*" does not exist in/, 'Value does not exist in');
|
|
64
|
+
return `${variable} got invalid value${at}; ${complaint}`;
|
|
65
|
+
};
|
|
44
66
|
/** The code a surfaced request error carries when graphql-js supplied none. */
|
|
45
67
|
const BAD_USER_INPUT = 'BAD_USER_INPUT';
|
|
46
68
|
/**
|
|
@@ -85,7 +107,7 @@ export const maskError = (error) => {
|
|
|
85
107
|
if (!effectiveCode && isRequestError(error)) {
|
|
86
108
|
extensions.code = BAD_USER_INPUT;
|
|
87
109
|
return {
|
|
88
|
-
message: error
|
|
110
|
+
message: withoutInputValues(error),
|
|
89
111
|
...(error.locations ? { locations: error.locations } : {}),
|
|
90
112
|
extensions,
|
|
91
113
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import '../middleware/types';
|
|
2
2
|
import { Logger } from '@pgpmjs/logger';
|
|
3
|
-
import {
|
|
3
|
+
import { getOperationAST, Kind, parse } from 'graphql';
|
|
4
|
+
import { DEVICE_TOKEN_COOKIE_NAME, getDeviceTokenCookieConfig, getSessionCookieConfig, isSessionCookieOmitted, SESSION_COOKIE_NAME, } from '../middleware/cookie';
|
|
4
5
|
const log = new Logger('auth-cookie');
|
|
5
6
|
/**
|
|
6
7
|
* Serialize a cookie to a Set-Cookie header value.
|
|
@@ -76,26 +77,21 @@ const SIGN_OUT_MUTATIONS = new Set([
|
|
|
76
77
|
'revokeAllSessions',
|
|
77
78
|
]);
|
|
78
79
|
/**
|
|
79
|
-
*
|
|
80
|
+
* Top-level field names of the mutation that will execute: the operation
|
|
81
|
+
* picked by `operationName`, so other operations in the document never count.
|
|
80
82
|
*/
|
|
81
|
-
const extractMutationNames = (query) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const extractMutationNames = (query, operationName) => {
|
|
84
|
+
let document;
|
|
85
|
+
try {
|
|
86
|
+
document = parse(query);
|
|
85
87
|
}
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
}
|
|
88
|
+
catch {
|
|
89
|
+
return [];
|
|
97
90
|
}
|
|
98
|
-
|
|
91
|
+
const operation = getOperationAST(document, operationName);
|
|
92
|
+
if (operation?.operation !== 'mutation')
|
|
93
|
+
return [];
|
|
94
|
+
return operation.selectionSet.selections.flatMap((selection) => selection.kind === Kind.FIELD ? [selection.name.value] : []);
|
|
99
95
|
};
|
|
100
96
|
/**
|
|
101
97
|
* Extract access token from mutation response.
|
|
@@ -198,7 +194,7 @@ export const AuthCookiePlugin = {
|
|
|
198
194
|
return result;
|
|
199
195
|
}
|
|
200
196
|
// Extract mutation names
|
|
201
|
-
const mutationNames = extractMutationNames(body.query);
|
|
197
|
+
const mutationNames = extractMutationNames(body.query, body.operationName);
|
|
202
198
|
if (mutationNames.length === 0) {
|
|
203
199
|
return result;
|
|
204
200
|
}
|
|
@@ -213,24 +209,25 @@ export const AuthCookiePlugin = {
|
|
|
213
209
|
// Parse response body
|
|
214
210
|
const payload = bufferResult.buffer.toString('utf8');
|
|
215
211
|
const graphqlResponse = JSON.parse(payload);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return result;
|
|
219
|
-
}
|
|
220
|
-
const data = graphqlResponse.data;
|
|
212
|
+
const succeeded = !graphqlResponse.errors?.length && Boolean(graphqlResponse.data);
|
|
213
|
+
const data = graphqlResponse.data ?? {};
|
|
221
214
|
const authSettings = req.api?.authSettings;
|
|
222
215
|
const cookiesToSet = [];
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
216
|
+
// signOut always clears the cookies, whatever the mutation returned
|
|
217
|
+
// and however the request authenticated: a Bearer client that was
|
|
218
|
+
// once handed a session cookie must not keep sending it afterwards.
|
|
219
|
+
// revokeSession/revokeAllSessions clear them only on success.
|
|
220
|
+
if (signOutMutation === 'signOut' || (signOutMutation && succeeded && data[signOutMutation])) {
|
|
221
|
+
log.info(`[auth-cookie] ${signOutMutation}: clearing session cookie`);
|
|
226
222
|
const config = getSessionCookieConfig(authSettings);
|
|
227
223
|
cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config));
|
|
228
224
|
// Also clear device token on sign-out
|
|
229
225
|
const deviceConfig = getDeviceTokenCookieConfig(authSettings);
|
|
230
226
|
cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
|
|
231
227
|
}
|
|
232
|
-
// Handle sign-in mutations
|
|
233
|
-
|
|
228
|
+
// Handle sign-in mutations; a client that sent the opt-out header
|
|
229
|
+
// reads the token from the response body and gets no cookies.
|
|
230
|
+
if (signInMutation && succeeded && !isSessionCookieOmitted(req)) {
|
|
234
231
|
const accessToken = extractAccessToken(data, signInMutation);
|
|
235
232
|
if (accessToken) {
|
|
236
233
|
const rememberMe = hasRememberMe(body.variables);
|
package/esm/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createApiMiddleware } from './middleware/api';
|
|
|
19
19
|
import { createAuthenticateMiddleware } from './middleware/auth';
|
|
20
20
|
// Auth cookie handling is done via AuthCookiePlugin in grafserv
|
|
21
21
|
import { createCaptchaMiddleware } from './middleware/captcha';
|
|
22
|
-
import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
22
|
+
import { parseCookieValue, rejectInvalidSessionCookieHeader, SESSION_COOKIE_NAME } from './middleware/cookie';
|
|
23
23
|
import { cors } from './middleware/cors';
|
|
24
24
|
import { errorHandler, notFoundHandler } from './middleware/error-handler';
|
|
25
25
|
import { favicon } from './middleware/favicon';
|
|
@@ -189,6 +189,9 @@ class Server {
|
|
|
189
189
|
};
|
|
190
190
|
app.use(csrfSetToken); // Set CSRF token cookie on all requests
|
|
191
191
|
app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
|
|
192
|
+
// The session-cookie opt-out does not bypass CSRF: it only stops sign-in
|
|
193
|
+
// responses from setting cookies (see AuthCookiePlugin).
|
|
194
|
+
app.use('/graphql', rejectInvalidSessionCookieHeader);
|
|
192
195
|
// LLM Agent REST API — mounted before graphile so SSE streaming
|
|
193
196
|
// routes are handled without going through PostGraphile
|
|
194
197
|
app.use(createAgenticRouter());
|
package/middleware/cookie.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type { Request, Response } from 'express';
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
2
|
import type { AuthSettings } from '../types';
|
|
3
3
|
export declare const SESSION_COOKIE_NAME = "constructive_session";
|
|
4
4
|
export declare const DEVICE_TOKEN_COOKIE_NAME = "constructive_device_token";
|
|
5
|
+
/**
|
|
6
|
+
* Request header a non-browser client (native app, CLI, server) sends to opt
|
|
7
|
+
* out of auth cookies: sign-in mutations then return the access token in the
|
|
8
|
+
* response body only and set neither the session nor the device-token cookie.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SESSION_COOKIE_HEADER = "X-Constructive-Session-Cookie";
|
|
11
|
+
export declare const SESSION_COOKIE_OMIT = "omit";
|
|
5
12
|
export interface CookieConfig {
|
|
6
13
|
secure: boolean;
|
|
7
14
|
sameSite: 'strict' | 'lax' | 'none';
|
|
@@ -34,6 +41,16 @@ export declare const setDeviceTokenCookie: (res: Response, deviceToken: string,
|
|
|
34
41
|
* Clear the device token cookie.
|
|
35
42
|
*/
|
|
36
43
|
export declare const clearDeviceTokenCookie: (res: Response, config: CookieConfig) => void;
|
|
44
|
+
/**
|
|
45
|
+
* True when the request opted out of auth cookies via `SESSION_COOKIE_HEADER`.
|
|
46
|
+
*/
|
|
47
|
+
export declare const isSessionCookieOmitted: (req: Request) => boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Reject a `SESSION_COOKIE_HEADER` with any value but `omit`, before the
|
|
50
|
+
* request executes, so a misspelt opt-out fails loudly instead of setting the
|
|
51
|
+
* cookie the client asked not to receive.
|
|
52
|
+
*/
|
|
53
|
+
export declare const rejectInvalidSessionCookieHeader: (req: Request, _res: Response, next: NextFunction) => void;
|
|
37
54
|
/**
|
|
38
55
|
* Parse a cookie value from the raw Cookie header.
|
|
39
56
|
* Avoids pulling in cookie-parser as a dependency.
|
package/middleware/cookie.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
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;
|
|
3
|
+
exports.getSessionTokenFromRequest = exports.getDeviceTokenFromRequest = exports.parseCookieValue = exports.rejectInvalidSessionCookieHeader = exports.isSessionCookieOmitted = exports.clearDeviceTokenCookie = exports.setDeviceTokenCookie = exports.clearSessionCookie = exports.setSessionCookie = exports.getDeviceTokenCookieConfig = exports.getSessionCookieConfig = exports.SESSION_COOKIE_OMIT = exports.SESSION_COOKIE_HEADER = exports.DEVICE_TOKEN_COOKIE_NAME = exports.SESSION_COOKIE_NAME = void 0;
|
|
4
|
+
const api_errors_1 = require("../errors/api-errors");
|
|
4
5
|
exports.SESSION_COOKIE_NAME = 'constructive_session';
|
|
5
6
|
exports.DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
|
|
7
|
+
/**
|
|
8
|
+
* Request header a non-browser client (native app, CLI, server) sends to opt
|
|
9
|
+
* out of auth cookies: sign-in mutations then return the access token in the
|
|
10
|
+
* response body only and set neither the session nor the device-token cookie.
|
|
11
|
+
*/
|
|
12
|
+
exports.SESSION_COOKIE_HEADER = 'X-Constructive-Session-Cookie';
|
|
13
|
+
exports.SESSION_COOKIE_OMIT = 'omit';
|
|
6
14
|
const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
|
|
7
15
|
/**
|
|
8
16
|
* Build cookie config from AuthSettings with optional remember_me override.
|
|
@@ -98,6 +106,24 @@ const clearDeviceTokenCookie = (res, config) => {
|
|
|
98
106
|
});
|
|
99
107
|
};
|
|
100
108
|
exports.clearDeviceTokenCookie = clearDeviceTokenCookie;
|
|
109
|
+
/**
|
|
110
|
+
* True when the request opted out of auth cookies via `SESSION_COOKIE_HEADER`.
|
|
111
|
+
*/
|
|
112
|
+
const isSessionCookieOmitted = (req) => req.get(exports.SESSION_COOKIE_HEADER) === exports.SESSION_COOKIE_OMIT;
|
|
113
|
+
exports.isSessionCookieOmitted = isSessionCookieOmitted;
|
|
114
|
+
/**
|
|
115
|
+
* Reject a `SESSION_COOKIE_HEADER` with any value but `omit`, before the
|
|
116
|
+
* request executes, so a misspelt opt-out fails loudly instead of setting the
|
|
117
|
+
* cookie the client asked not to receive.
|
|
118
|
+
*/
|
|
119
|
+
const rejectInvalidSessionCookieHeader = (req, _res, next) => {
|
|
120
|
+
const value = req.get(exports.SESSION_COOKIE_HEADER);
|
|
121
|
+
if (value !== undefined && value !== exports.SESSION_COOKIE_OMIT) {
|
|
122
|
+
return next(new api_errors_1.InvalidSessionCookieHeaderError(exports.SESSION_COOKIE_HEADER, value, exports.SESSION_COOKIE_OMIT));
|
|
123
|
+
}
|
|
124
|
+
next();
|
|
125
|
+
};
|
|
126
|
+
exports.rejectInvalidSessionCookieHeader = rejectInvalidSessionCookieHeader;
|
|
101
127
|
/**
|
|
102
128
|
* Parse a cookie value from the raw Cookie header.
|
|
103
129
|
* Avoids pulling in cookie-parser as a dependency.
|
package/middleware/mask-error.js
CHANGED
|
@@ -47,6 +47,28 @@ const isGraphQLLayerError = (value) => value == null ||
|
|
|
47
47
|
(value.name === 'GraphQLError' &&
|
|
48
48
|
isGraphQLLayerError(value.originalError));
|
|
49
49
|
const isRequestError = (error) => error.path == null && isGraphQLLayerError(error.originalError);
|
|
50
|
+
/**
|
|
51
|
+
* A request error's message without the values the request sent. graphql-js
|
|
52
|
+
* writes a variable coercion failure as `Variable "$x" got invalid value <the
|
|
53
|
+
* value> at "<path>"; <inner>`, and a leaf type's inner complaint ends in the
|
|
54
|
+
* value too (`String cannot represent a non string value: <value>`,
|
|
55
|
+
* `Value "<value>" does not exist in "E" enum.`), so a password or API key sent
|
|
56
|
+
* in a malformed input would come back in the answer. The variable, the path and
|
|
57
|
+
* what was wrong stay; the values go.
|
|
58
|
+
*/
|
|
59
|
+
const withoutInputValues = (error) => {
|
|
60
|
+
const inner = error.originalError?.message;
|
|
61
|
+
const message = error.message;
|
|
62
|
+
if (!message.startsWith('Variable "$') || !inner || !message.endsWith(`; ${inner}`))
|
|
63
|
+
return message;
|
|
64
|
+
const prefix = message.slice(0, message.length - inner.length - 2);
|
|
65
|
+
const variable = /^Variable "\$[^"]+"/.exec(prefix)?.[0];
|
|
66
|
+
const at = / at "[^"]*"$/.exec(prefix)?.[0] ?? '';
|
|
67
|
+
const complaint = inner
|
|
68
|
+
.replace(/(value): [\s\S]*$/, '$1.')
|
|
69
|
+
.replace(/^Value "[\s\S]*" does not exist in/, 'Value does not exist in');
|
|
70
|
+
return `${variable} got invalid value${at}; ${complaint}`;
|
|
71
|
+
};
|
|
50
72
|
/** The code a surfaced request error carries when graphql-js supplied none. */
|
|
51
73
|
const BAD_USER_INPUT = 'BAD_USER_INPUT';
|
|
52
74
|
/**
|
|
@@ -92,7 +114,7 @@ const maskError = (error) => {
|
|
|
92
114
|
if (!effectiveCode && isRequestError(error)) {
|
|
93
115
|
extensions.code = BAD_USER_INPUT;
|
|
94
116
|
return {
|
|
95
|
-
message: error
|
|
117
|
+
message: withoutInputValues(error),
|
|
96
118
|
...(error.locations ? { locations: error.locations } : {}),
|
|
97
119
|
extensions,
|
|
98
120
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.28.
|
|
3
|
+
"version": "5.28.9",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -42,20 +42,20 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@agentic-kit/ollama": "2.15.0",
|
|
45
|
-
"@constructive-io/csrf": "^0.30.
|
|
46
|
-
"@constructive-io/errors": "^0.
|
|
47
|
-
"@constructive-io/express-context": "^0.33.
|
|
48
|
-
"@constructive-io/graphql-env": "^3.34.
|
|
49
|
-
"@constructive-io/graphql-types": "^3.33.
|
|
45
|
+
"@constructive-io/csrf": "^0.30.1",
|
|
46
|
+
"@constructive-io/errors": "^0.23.0",
|
|
47
|
+
"@constructive-io/express-context": "^0.33.6",
|
|
48
|
+
"@constructive-io/graphql-env": "^3.34.6",
|
|
49
|
+
"@constructive-io/graphql-types": "^3.33.6",
|
|
50
50
|
"@constructive-io/llm-env": "^0.15.0",
|
|
51
|
-
"@constructive-io/query-builder": "^3.16.
|
|
51
|
+
"@constructive-io/query-builder": "^3.16.7",
|
|
52
52
|
"@constructive-io/s3-utils": "^2.34.0",
|
|
53
53
|
"@constructive-io/url-domains": "^2.31.0",
|
|
54
54
|
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
|
|
55
|
-
"@pgpmjs/env": "^2.46.
|
|
55
|
+
"@pgpmjs/env": "^2.46.3",
|
|
56
56
|
"@pgpmjs/logger": "^2.26.0",
|
|
57
|
-
"@pgpmjs/server-utils": "^3.29.
|
|
58
|
-
"@pgpmjs/types": "^2.56.
|
|
57
|
+
"@pgpmjs/server-utils": "^3.29.6",
|
|
58
|
+
"@pgpmjs/types": "^2.56.3",
|
|
59
59
|
"cors": "^2.8.6",
|
|
60
60
|
"deepmerge": "^4.3.1",
|
|
61
61
|
"express": "^5.2.1",
|
|
@@ -64,17 +64,17 @@
|
|
|
64
64
|
"grafserv": "1.0.1",
|
|
65
65
|
"graphile-build": "5.1.1",
|
|
66
66
|
"graphile-build-pg": "5.1.3",
|
|
67
|
-
"graphile-cache": "^4.14.
|
|
67
|
+
"graphile-cache": "^4.14.6",
|
|
68
68
|
"graphile-config": "1.1.0",
|
|
69
|
-
"graphile-function-bindings": "^1.17.
|
|
70
|
-
"graphile-scoped-introspection": "^0.4.
|
|
71
|
-
"graphile-settings": "^6.24.
|
|
69
|
+
"graphile-function-bindings": "^1.17.7",
|
|
70
|
+
"graphile-scoped-introspection": "^0.4.7",
|
|
71
|
+
"graphile-settings": "^6.24.8",
|
|
72
72
|
"graphile-utils": "5.0.3",
|
|
73
73
|
"graphql": "16.13.0",
|
|
74
74
|
"graphql-upload": "^13.0.0",
|
|
75
75
|
"lru-cache": "^11.2.7",
|
|
76
76
|
"pg": "^8.21.0",
|
|
77
|
-
"pg-cache": "^3.29.
|
|
77
|
+
"pg-cache": "^3.29.6",
|
|
78
78
|
"pg-env": "^1.32.0",
|
|
79
79
|
"pg-query-context": "^2.31.0",
|
|
80
80
|
"pg-sql2": "5.0.1",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"@types/request-ip": "^0.0.41",
|
|
92
92
|
"@types/supertest": "^7.2.1",
|
|
93
93
|
"cookie-parser": "^1.4.7",
|
|
94
|
-
"graphile-test": "5.17.
|
|
94
|
+
"graphile-test": "5.17.7",
|
|
95
95
|
"makage": "^0.8.0",
|
|
96
96
|
"nodemon": "^3.1.14",
|
|
97
97
|
"supertest": "^7.2.2",
|
|
98
98
|
"ts-node": "^10.9.2"
|
|
99
99
|
},
|
|
100
|
-
"gitHead": "
|
|
100
|
+
"gitHead": "a95137c03998f4ef18aaba770bdeb598077e5b51"
|
|
101
101
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.AuthCookiePlugin = void 0;
|
|
4
4
|
require("../middleware/types");
|
|
5
5
|
const logger_1 = require("@pgpmjs/logger");
|
|
6
|
+
const graphql_1 = require("graphql");
|
|
6
7
|
const cookie_1 = require("../middleware/cookie");
|
|
7
8
|
const log = new logger_1.Logger('auth-cookie');
|
|
8
9
|
/**
|
|
@@ -79,26 +80,21 @@ const SIGN_OUT_MUTATIONS = new Set([
|
|
|
79
80
|
'revokeAllSessions',
|
|
80
81
|
]);
|
|
81
82
|
/**
|
|
82
|
-
*
|
|
83
|
+
* Top-level field names of the mutation that will execute: the operation
|
|
84
|
+
* picked by `operationName`, so other operations in the document never count.
|
|
83
85
|
*/
|
|
84
|
-
const extractMutationNames = (query) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const extractMutationNames = (query, operationName) => {
|
|
87
|
+
let document;
|
|
88
|
+
try {
|
|
89
|
+
document = (0, graphql_1.parse)(query);
|
|
88
90
|
}
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
100
93
|
}
|
|
101
|
-
|
|
94
|
+
const operation = (0, graphql_1.getOperationAST)(document, operationName);
|
|
95
|
+
if (operation?.operation !== 'mutation')
|
|
96
|
+
return [];
|
|
97
|
+
return operation.selectionSet.selections.flatMap((selection) => selection.kind === graphql_1.Kind.FIELD ? [selection.name.value] : []);
|
|
102
98
|
};
|
|
103
99
|
/**
|
|
104
100
|
* Extract access token from mutation response.
|
|
@@ -201,7 +197,7 @@ exports.AuthCookiePlugin = {
|
|
|
201
197
|
return result;
|
|
202
198
|
}
|
|
203
199
|
// Extract mutation names
|
|
204
|
-
const mutationNames = extractMutationNames(body.query);
|
|
200
|
+
const mutationNames = extractMutationNames(body.query, body.operationName);
|
|
205
201
|
if (mutationNames.length === 0) {
|
|
206
202
|
return result;
|
|
207
203
|
}
|
|
@@ -216,24 +212,25 @@ exports.AuthCookiePlugin = {
|
|
|
216
212
|
// Parse response body
|
|
217
213
|
const payload = bufferResult.buffer.toString('utf8');
|
|
218
214
|
const graphqlResponse = JSON.parse(payload);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return result;
|
|
222
|
-
}
|
|
223
|
-
const data = graphqlResponse.data;
|
|
215
|
+
const succeeded = !graphqlResponse.errors?.length && Boolean(graphqlResponse.data);
|
|
216
|
+
const data = graphqlResponse.data ?? {};
|
|
224
217
|
const authSettings = req.api?.authSettings;
|
|
225
218
|
const cookiesToSet = [];
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
219
|
+
// signOut always clears the cookies, whatever the mutation returned
|
|
220
|
+
// and however the request authenticated: a Bearer client that was
|
|
221
|
+
// once handed a session cookie must not keep sending it afterwards.
|
|
222
|
+
// revokeSession/revokeAllSessions clear them only on success.
|
|
223
|
+
if (signOutMutation === 'signOut' || (signOutMutation && succeeded && data[signOutMutation])) {
|
|
224
|
+
log.info(`[auth-cookie] ${signOutMutation}: clearing session cookie`);
|
|
229
225
|
const config = (0, cookie_1.getSessionCookieConfig)(authSettings);
|
|
230
226
|
cookiesToSet.push(serializeClearCookie(cookie_1.SESSION_COOKIE_NAME, config));
|
|
231
227
|
// Also clear device token on sign-out
|
|
232
228
|
const deviceConfig = (0, cookie_1.getDeviceTokenCookieConfig)(authSettings);
|
|
233
229
|
cookiesToSet.push(serializeClearCookie(cookie_1.DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
|
|
234
230
|
}
|
|
235
|
-
// Handle sign-in mutations
|
|
236
|
-
|
|
231
|
+
// Handle sign-in mutations; a client that sent the opt-out header
|
|
232
|
+
// reads the token from the response body and gets no cookies.
|
|
233
|
+
if (signInMutation && succeeded && !(0, cookie_1.isSessionCookieOmitted)(req)) {
|
|
237
234
|
const accessToken = extractAccessToken(data, signInMutation);
|
|
238
235
|
if (accessToken) {
|
|
239
236
|
const rememberMe = hasRememberMe(body.variables);
|
package/server.js
CHANGED
|
@@ -196,6 +196,9 @@ class Server {
|
|
|
196
196
|
};
|
|
197
197
|
app.use(csrfSetToken); // Set CSRF token cookie on all requests
|
|
198
198
|
app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
|
|
199
|
+
// The session-cookie opt-out does not bypass CSRF: it only stops sign-in
|
|
200
|
+
// responses from setting cookies (see AuthCookiePlugin).
|
|
201
|
+
app.use('/graphql', cookie_1.rejectInvalidSessionCookieHeader);
|
|
199
202
|
// LLM Agent REST API — mounted before graphile so SSE streaming
|
|
200
203
|
// routes are handled without going through PostGraphile
|
|
201
204
|
app.use((0, agentic_1.createAgenticRouter)());
|