@constructive-io/graphql-server 5.28.8 → 5.29.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/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/error-handler.js +5 -20
- package/esm/middleware/{mask-error.js → format-error.js} +37 -62
- package/esm/middleware/graphile.js +10 -8
- package/esm/plugins/auth-cookie-plugin.js +27 -29
- package/esm/plugins/error-events-plugin.js +3 -3
- package/esm/plugins/webauthn-plugin.js +392 -0
- package/esm/server.js +4 -1
- package/middleware/cookie.d.ts +18 -1
- package/middleware/cookie.js +27 -1
- package/middleware/error-handler.js +5 -20
- package/middleware/format-error.d.ts +28 -0
- package/middleware/{mask-error.js → format-error.js} +38 -63
- package/middleware/graphile.js +10 -8
- package/package.json +21 -19
- package/plugins/auth-cookie-plugin.js +26 -28
- package/plugins/error-events-plugin.js +4 -4
- package/plugins/webauthn-plugin.d.ts +3 -0
- package/plugins/webauthn-plugin.js +395 -0
- package/server.js +3 -0
- package/middleware/mask-error.d.ts +0 -27
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.
|
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import './types';
|
|
2
|
-
import { getNodeEnv } from '@pgpmjs/env';
|
|
3
2
|
import { Logger } from '@pgpmjs/logger';
|
|
4
3
|
import errorPage50x from '../errors/50x';
|
|
5
4
|
import errorPage404Message from '../errors/404-message';
|
|
6
5
|
import { isApiError } from '../errors/api-errors';
|
|
7
6
|
const log = new Logger('error-handler');
|
|
8
|
-
const isDevelopment = () => getNodeEnv() === 'development';
|
|
9
7
|
const wantsJson = (req) => {
|
|
10
8
|
const accept = req.get('Accept') || '';
|
|
11
9
|
return accept.includes('application/json')
|
|
12
10
|
|| accept.includes('application/graphql-response+json')
|
|
13
11
|
|| Boolean(req.is('json'));
|
|
14
12
|
};
|
|
15
|
-
const sanitizeMessage = (error) => {
|
|
16
|
-
if (isDevelopment())
|
|
17
|
-
return error.message;
|
|
18
|
-
if (isApiError(error))
|
|
19
|
-
return error.message;
|
|
20
|
-
if (error.message?.includes('ECONNREFUSED'))
|
|
21
|
-
return 'Service temporarily unavailable';
|
|
22
|
-
if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT'))
|
|
23
|
-
return 'Request timed out';
|
|
24
|
-
if (error.message?.includes('does not exist'))
|
|
25
|
-
return 'The requested resource does not exist';
|
|
26
|
-
return 'An unexpected error occurred';
|
|
27
|
-
};
|
|
28
13
|
const isCsrfError = (err) => {
|
|
29
14
|
const code = err.code;
|
|
30
15
|
return typeof code === 'string' && code.startsWith('CSRF_');
|
|
@@ -34,7 +19,7 @@ const categorizeError = (err) => {
|
|
|
34
19
|
return {
|
|
35
20
|
statusCode: err.statusCode,
|
|
36
21
|
code: err.code,
|
|
37
|
-
message:
|
|
22
|
+
message: err.message,
|
|
38
23
|
logLevel: err.statusCode >= 500 ? 'error' : 'warn',
|
|
39
24
|
};
|
|
40
25
|
}
|
|
@@ -43,12 +28,12 @@ const categorizeError = (err) => {
|
|
|
43
28
|
return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
|
|
44
29
|
}
|
|
45
30
|
if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
|
|
46
|
-
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message:
|
|
31
|
+
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: err.message, logLevel: 'error' };
|
|
47
32
|
}
|
|
48
33
|
if (err.message?.includes('timeout') || err.message?.includes('ETIMEDOUT')) {
|
|
49
|
-
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message:
|
|
34
|
+
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: err.message, logLevel: 'error' };
|
|
50
35
|
}
|
|
51
|
-
return { statusCode: 500, code: 'INTERNAL_ERROR', message:
|
|
36
|
+
return { statusCode: 500, code: 'INTERNAL_ERROR', message: err.message, logLevel: 'error' };
|
|
52
37
|
};
|
|
53
38
|
const sendResponse = (req, res, { statusCode, code, message }) => {
|
|
54
39
|
if (wantsJson(req)) {
|
|
@@ -72,7 +57,7 @@ const logError = (err, req, level) => {
|
|
|
72
57
|
log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context });
|
|
73
58
|
}
|
|
74
59
|
else {
|
|
75
|
-
log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack:
|
|
60
|
+
log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: err.stack, ...context });
|
|
76
61
|
}
|
|
77
62
|
};
|
|
78
63
|
export const errorHandler = (err, req, res, _next) => {
|
|
@@ -1,39 +1,20 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { getNodeEnv } from '@pgpmjs/env';
|
|
2
|
+
import { parse } from '@constructive-io/errors';
|
|
4
3
|
import { Logger } from '@pgpmjs/logger';
|
|
5
|
-
const
|
|
6
|
-
/**
|
|
7
|
-
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
8
|
-
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
9
|
-
* codes in the `@constructive-io/errors` registry. They are always safe to
|
|
10
|
-
* surface — they carry no sensitive detail. Everything else (auth, account,
|
|
11
|
-
* resource, constraint, and every constructive-db code) is classified by the
|
|
12
|
-
* registry, which is the single source of truth for public vs. internal.
|
|
13
|
-
*/
|
|
14
|
-
const GRAPHQL_PROTOCOL_CODES = new Set([
|
|
15
|
-
'GRAPHQL_VALIDATION_FAILED',
|
|
16
|
-
'GRAPHQL_PARSE_FAILED',
|
|
17
|
-
'PERSISTED_QUERY_NOT_FOUND',
|
|
18
|
-
'PERSISTED_QUERY_NOT_SUPPORTED'
|
|
19
|
-
]);
|
|
20
|
-
/** A code is safe to surface when the registry classifies it public, or it is a
|
|
21
|
-
* GraphQL framework protocol code. */
|
|
22
|
-
const isPublicCode = (code) => Boolean(code) && (classify(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
|
|
4
|
+
const formatErrorLog = new Logger('graphile:formatError');
|
|
23
5
|
/**
|
|
24
6
|
* An error the GraphQL layer raised about the *request*, before any resolver
|
|
25
7
|
* ran: an unknown input field, a value of the wrong type, a missing required
|
|
26
8
|
* variable. graphql-js reports variable coercion without an `extensions.code`
|
|
27
9
|
* (unlike parse/validation, which carry `GRAPHQL_PARSE_FAILED` /
|
|
28
|
-
* `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone
|
|
29
|
-
*
|
|
30
|
-
* failure, with a reference id pointing at nothing.
|
|
10
|
+
* `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone would read
|
|
11
|
+
* it as a server failure instead of the client's own malformed query.
|
|
31
12
|
*
|
|
32
13
|
* A request error is answered before a field is resolved, so it carries no
|
|
33
14
|
* response `path` — every execution error has one. Coercion wraps the inner
|
|
34
15
|
* complaint about the value, so `originalError` may be set, but only ever to
|
|
35
16
|
* another GraphQL-layer error: anything a resolver or the database threw arrives
|
|
36
|
-
* as a foreign error (a pg error, an `Error`)
|
|
17
|
+
* as a foreign error (a pg error, an `Error`). The wrap
|
|
37
18
|
* is recognized by name rather than by `instanceof`, because the error is raised
|
|
38
19
|
* by whichever copy of graphql-js grafast resolved, not by this package's.
|
|
39
20
|
*/
|
|
@@ -63,8 +44,10 @@ const withoutInputValues = (error) => {
|
|
|
63
44
|
.replace(/^Value "[\s\S]*" does not exist in/, 'Value does not exist in');
|
|
64
45
|
return `${variable} got invalid value${at}; ${complaint}`;
|
|
65
46
|
};
|
|
66
|
-
/** The code a
|
|
47
|
+
/** The code a request error carries when graphql-js supplied none. */
|
|
67
48
|
const BAD_USER_INPUT = 'BAD_USER_INPUT';
|
|
49
|
+
/** The code any other error carries when nothing supplied one. */
|
|
50
|
+
const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR';
|
|
68
51
|
/**
|
|
69
52
|
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
70
53
|
*
|
|
@@ -80,21 +63,21 @@ export const normalizeError = (error) => {
|
|
|
80
63
|
return { code: parsed.code, context: parsed.context, class: parsed.class };
|
|
81
64
|
};
|
|
82
65
|
/**
|
|
83
|
-
*
|
|
66
|
+
* Format every GraphQL error the same way, in every environment. Nothing is
|
|
67
|
+
* masked: the client always receives the real message.
|
|
84
68
|
*
|
|
85
|
-
* 1.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* 2.
|
|
89
|
-
* 3.
|
|
90
|
-
* 4.
|
|
91
|
-
* the
|
|
69
|
+
* 1. Lift the structured code onto `extensions.code`/`class`/`context` from the
|
|
70
|
+
* parsed error, so database errors reach clients with a machine-readable
|
|
71
|
+
* code instead of a bare message with empty `extensions`.
|
|
72
|
+
* 2. A request error graphql-js raised without a code is `BAD_USER_INPUT`.
|
|
73
|
+
* 3. Any other error without a code is `INTERNAL_SERVER_ERROR`.
|
|
74
|
+
* 4. Every internal error (unknown, or registered as internal) carries an
|
|
75
|
+
* `errorId` and is logged under it, so a report can be matched to the log.
|
|
92
76
|
*/
|
|
93
|
-
export const
|
|
77
|
+
export const formatError = (error) => {
|
|
94
78
|
const { code, context, class: errorClass } = normalizeError(error);
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// on GraphQLError, so we build a formatted error rather than mutating it).
|
|
79
|
+
// `extensions` is read-only on GraphQLError, so build a formatted error
|
|
80
|
+
// rather than mutating it.
|
|
98
81
|
const extensions = { ...error.extensions };
|
|
99
82
|
if (code) {
|
|
100
83
|
extensions.code = code;
|
|
@@ -103,33 +86,25 @@ export const maskError = (error) => {
|
|
|
103
86
|
extensions.context = context;
|
|
104
87
|
}
|
|
105
88
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
89
|
+
let internal = Boolean(code) && errorClass === 'internal';
|
|
90
|
+
let message = error.message;
|
|
91
|
+
if (!code && !error.extensions?.code) {
|
|
92
|
+
const requestError = isRequestError(error);
|
|
93
|
+
if (requestError)
|
|
94
|
+
message = withoutInputValues(error);
|
|
95
|
+
extensions.code = requestError ? BAD_USER_INPUT : INTERNAL_SERVER_ERROR;
|
|
96
|
+
internal = !requestError;
|
|
114
97
|
}
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
message: error.message,
|
|
120
|
-
...(error.locations ? { locations: error.locations } : {}),
|
|
121
|
-
...(error.path ? { path: error.path } : {}),
|
|
122
|
-
extensions,
|
|
123
|
-
};
|
|
98
|
+
if (internal) {
|
|
99
|
+
const errorId = crypto.randomBytes(8).toString('hex');
|
|
100
|
+
extensions.errorId = errorId;
|
|
101
|
+
formatErrorLog.error(`[graphql-error:${errorId}]`, error);
|
|
124
102
|
}
|
|
125
|
-
//
|
|
126
|
-
const errorId = crypto.randomBytes(8).toString('hex');
|
|
127
|
-
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
103
|
+
// grafserv strips originalError before serializing to the client.
|
|
128
104
|
return {
|
|
129
|
-
message
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
105
|
+
message,
|
|
106
|
+
...(error.locations ? { locations: error.locations } : {}),
|
|
107
|
+
...(error.path ? { path: error.path } : {}),
|
|
108
|
+
extensions
|
|
134
109
|
};
|
|
135
110
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import './types'; // for Request type
|
|
2
2
|
import { errors } from '@constructive-io/errors';
|
|
3
3
|
import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context';
|
|
4
|
-
import { getNodeEnv } from '@pgpmjs/env';
|
|
5
4
|
import { Logger } from '@pgpmjs/logger';
|
|
6
5
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
7
6
|
import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
|
|
@@ -14,10 +13,10 @@ import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
|
14
13
|
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
15
14
|
import { createErrorEventsPlugin } from '../plugins/error-events-plugin';
|
|
16
15
|
import { RequestProtectionPlugin } from '../plugins/request-protection-plugin';
|
|
16
|
+
import { WebauthnPlugin } from '../plugins/webauthn-plugin';
|
|
17
|
+
import { formatError } from './format-error';
|
|
17
18
|
import { makeIntrospectionWiring } from './graphile-introspection';
|
|
18
|
-
import { maskError } from './mask-error';
|
|
19
19
|
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
20
|
-
const isDev = () => getNodeEnv() === 'development';
|
|
21
20
|
// =============================================================================
|
|
22
21
|
// Single-Flight Pattern: In-Flight Tracking
|
|
23
22
|
// =============================================================================
|
|
@@ -57,7 +56,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
57
56
|
* plugin preset. Without settings the default preset is used
|
|
58
57
|
* (everything on except aggregates).
|
|
59
58
|
*/
|
|
60
|
-
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute) => {
|
|
59
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
61
60
|
const introspection = await makeIntrospectionWiring(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
62
61
|
const configuredPreset = graphileOptions?.preset ?? {};
|
|
63
62
|
const grafastCachePreset = createGrafastCacheLimitsPreset(graphileOptions?.grafastCache);
|
|
@@ -77,6 +76,9 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
77
76
|
AuthCookiePlugin,
|
|
78
77
|
RequestProtectionPlugin,
|
|
79
78
|
createErrorEventsPlugin(pool),
|
|
79
|
+
// Only when the tenant provisioned the WebAuthn module; the policy
|
|
80
|
+
// itself is read per request from `req.api.webauthnSettings`.
|
|
81
|
+
...(webauthn ? [WebauthnPlugin] : []),
|
|
80
82
|
// Only registered when the compute module is provisioned for this
|
|
81
83
|
// database — all schema/table names come from the constructive
|
|
82
84
|
// metaschema (express-context compute module loader); the plugin has
|
|
@@ -103,10 +105,10 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
103
105
|
graphiqlPath: '/graphiql',
|
|
104
106
|
graphiql: true,
|
|
105
107
|
graphiqlOnGraphQLGET: false,
|
|
106
|
-
maskError
|
|
108
|
+
maskError: formatError
|
|
107
109
|
},
|
|
108
110
|
grafast: {
|
|
109
|
-
explain:
|
|
111
|
+
explain: graphileOptions?.explain === true,
|
|
110
112
|
context: (requestContext) => {
|
|
111
113
|
// In grafserv/express/v4, the request is available at requestContext.expressv4.req
|
|
112
114
|
const req = requestContext?.expressv4?.req;
|
|
@@ -309,7 +311,7 @@ export const graphile = (opts) => {
|
|
|
309
311
|
// concurrent requests coalesce while optional plugin modules load.
|
|
310
312
|
const creationPromise = (async () => {
|
|
311
313
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
312
|
-
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute);
|
|
314
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
313
315
|
return observeGraphileBuild({
|
|
314
316
|
cacheKey: key,
|
|
315
317
|
serviceKey: key,
|
|
@@ -343,7 +345,7 @@ export const graphile = (opts) => {
|
|
|
343
345
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
344
346
|
if (!res.headersSent) {
|
|
345
347
|
respondWithGraphQLError(res, errors.INTERNAL_FAILURE({
|
|
346
|
-
details:
|
|
348
|
+
details: e?.message ?? String(e)
|
|
347
349
|
}));
|
|
348
350
|
return;
|
|
349
351
|
}
|
|
@@ -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.
|
|
@@ -66,6 +67,7 @@ const SIGN_IN_MUTATIONS = new Set([
|
|
|
66
67
|
'completeMfaChallenge',
|
|
67
68
|
'signInOneTimeToken',
|
|
68
69
|
'signInCrossOrigin',
|
|
70
|
+
'webauthnSignInFinish',
|
|
69
71
|
]);
|
|
70
72
|
/**
|
|
71
73
|
* Auth mutations that should clear the session cookie.
|
|
@@ -76,26 +78,21 @@ const SIGN_OUT_MUTATIONS = new Set([
|
|
|
76
78
|
'revokeAllSessions',
|
|
77
79
|
]);
|
|
78
80
|
/**
|
|
79
|
-
*
|
|
81
|
+
* Top-level field names of the mutation that will execute: the operation
|
|
82
|
+
* picked by `operationName`, so other operations in the document never count.
|
|
80
83
|
*/
|
|
81
|
-
const extractMutationNames = (query) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
const extractMutationNames = (query, operationName) => {
|
|
85
|
+
let document;
|
|
86
|
+
try {
|
|
87
|
+
document = parse(query);
|
|
85
88
|
}
|
|
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
|
-
}
|
|
89
|
+
catch {
|
|
90
|
+
return [];
|
|
97
91
|
}
|
|
98
|
-
|
|
92
|
+
const operation = getOperationAST(document, operationName);
|
|
93
|
+
if (operation?.operation !== 'mutation')
|
|
94
|
+
return [];
|
|
95
|
+
return operation.selectionSet.selections.flatMap((selection) => selection.kind === Kind.FIELD ? [selection.name.value] : []);
|
|
99
96
|
};
|
|
100
97
|
/**
|
|
101
98
|
* Extract access token from mutation response.
|
|
@@ -198,7 +195,7 @@ export const AuthCookiePlugin = {
|
|
|
198
195
|
return result;
|
|
199
196
|
}
|
|
200
197
|
// Extract mutation names
|
|
201
|
-
const mutationNames = extractMutationNames(body.query);
|
|
198
|
+
const mutationNames = extractMutationNames(body.query, body.operationName);
|
|
202
199
|
if (mutationNames.length === 0) {
|
|
203
200
|
return result;
|
|
204
201
|
}
|
|
@@ -213,24 +210,25 @@ export const AuthCookiePlugin = {
|
|
|
213
210
|
// Parse response body
|
|
214
211
|
const payload = bufferResult.buffer.toString('utf8');
|
|
215
212
|
const graphqlResponse = JSON.parse(payload);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return result;
|
|
219
|
-
}
|
|
220
|
-
const data = graphqlResponse.data;
|
|
213
|
+
const succeeded = !graphqlResponse.errors?.length && Boolean(graphqlResponse.data);
|
|
214
|
+
const data = graphqlResponse.data ?? {};
|
|
221
215
|
const authSettings = req.api?.authSettings;
|
|
222
216
|
const cookiesToSet = [];
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
217
|
+
// signOut always clears the cookies, whatever the mutation returned
|
|
218
|
+
// and however the request authenticated: a Bearer client that was
|
|
219
|
+
// once handed a session cookie must not keep sending it afterwards.
|
|
220
|
+
// revokeSession/revokeAllSessions clear them only on success.
|
|
221
|
+
if (signOutMutation === 'signOut' || (signOutMutation && succeeded && data[signOutMutation])) {
|
|
222
|
+
log.info(`[auth-cookie] ${signOutMutation}: clearing session cookie`);
|
|
226
223
|
const config = getSessionCookieConfig(authSettings);
|
|
227
224
|
cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config));
|
|
228
225
|
// Also clear device token on sign-out
|
|
229
226
|
const deviceConfig = getDeviceTokenCookieConfig(authSettings);
|
|
230
227
|
cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
|
|
231
228
|
}
|
|
232
|
-
// Handle sign-in mutations
|
|
233
|
-
|
|
229
|
+
// Handle sign-in mutations; a client that sent the opt-out header
|
|
230
|
+
// reads the token from the response body and gets no cookies.
|
|
231
|
+
if (signInMutation && succeeded && !isSessionCookieOmitted(req)) {
|
|
234
232
|
const accessToken = extractAccessToken(data, signInMutation);
|
|
235
233
|
if (accessToken) {
|
|
236
234
|
const rememberMe = hasRememberMe(body.variables);
|
|
@@ -3,13 +3,13 @@ import { Logger } from '@pgpmjs/logger';
|
|
|
3
3
|
import { getOperationAST } from 'graphql';
|
|
4
4
|
import { escapeIdentifier } from 'pg';
|
|
5
5
|
import { withPgClient } from 'pg-query-context';
|
|
6
|
-
import { normalizeError } from '../middleware/
|
|
6
|
+
import { normalizeError } from '../middleware/format-error';
|
|
7
7
|
const log = new Logger('error-events');
|
|
8
8
|
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
9
9
|
/**
|
|
10
10
|
* The first structured, public-classified registry code among the errors.
|
|
11
|
-
* Internal/unknown errors are bugs, not refusals: they are
|
|
12
|
-
*
|
|
11
|
+
* Internal/unknown errors are bugs, not refusals: they are logged by
|
|
12
|
+
* `formatError` and never recorded as tenant events.
|
|
13
13
|
*/
|
|
14
14
|
const refusalCode = (errors) => {
|
|
15
15
|
for (const error of errors ?? []) {
|