@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
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import '../middleware/types';
|
|
2
|
+
import { QuoteUtils } from '@pgsql/quotes';
|
|
3
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
|
|
4
|
+
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
5
|
+
import { context as grafastContext, lambda, object } from 'grafast';
|
|
6
|
+
import { SYSTEM_LANE_ROLE } from 'graphile-plugin-utils';
|
|
7
|
+
import { extendSchema, gql } from 'graphile-utils';
|
|
8
|
+
const CONTEXT_KEY = 'constructiveWebauthn';
|
|
9
|
+
const REGISTER_CHALLENGE = 'webauthn_register_challenge';
|
|
10
|
+
const SIGN_IN_CHALLENGE = 'webauthn_sign_in_challenge';
|
|
11
|
+
const MAX_NAME_LENGTH = 256;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
13
|
+
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
14
|
+
const fail = (code, message) => {
|
|
15
|
+
const err = new Error(message);
|
|
16
|
+
err.extensions = { code };
|
|
17
|
+
throw err;
|
|
18
|
+
};
|
|
19
|
+
/** Apply the request's settings transaction-locally, optionally overriding its role. */
|
|
20
|
+
const applySettings = async (tx, pgSettings, role) => {
|
|
21
|
+
const entries = Object.entries(pgSettings ?? {}).filter(([key]) => !role || key !== 'role');
|
|
22
|
+
if (role)
|
|
23
|
+
entries.push(['role', role]);
|
|
24
|
+
if (entries.length === 0)
|
|
25
|
+
return;
|
|
26
|
+
await tx.query({
|
|
27
|
+
text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
|
|
28
|
+
values: [JSON.stringify(entries)]
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const withRequestLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
32
|
+
await applySettings(tx, pgSettings);
|
|
33
|
+
return cb(tx);
|
|
34
|
+
}));
|
|
35
|
+
const withVerifierLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
36
|
+
await applySettings(tx, pgSettings, SYSTEM_LANE_ROLE);
|
|
37
|
+
return cb(tx);
|
|
38
|
+
}));
|
|
39
|
+
const procedure = (settings, name) => QuoteUtils.quoteQualifiedIdentifier(settings.schema, name);
|
|
40
|
+
const requireSettings = (ctx) => {
|
|
41
|
+
const settings = ctx?.settings;
|
|
42
|
+
if (!settings)
|
|
43
|
+
throw new Error('WebauthnPlugin is loaded but the request resolved no webauthnSettings');
|
|
44
|
+
return settings;
|
|
45
|
+
};
|
|
46
|
+
const requireUserId = (pgSettings) => {
|
|
47
|
+
const userId = pgSettings?.['jwt.claims.user_id'];
|
|
48
|
+
if (!userId)
|
|
49
|
+
fail('UNAUTHENTICATED', 'Passkey registration requires a signed-in user');
|
|
50
|
+
return userId;
|
|
51
|
+
};
|
|
52
|
+
const payloadObject = (operation, value) => {
|
|
53
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
54
|
+
throw new Error(`${operation} returned no payload`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
};
|
|
58
|
+
const payloadString = (operation, payload, key) => {
|
|
59
|
+
const value = payload[key];
|
|
60
|
+
if (typeof value !== 'string' || !value)
|
|
61
|
+
throw new Error(`${operation} returned no ${key}`);
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
const credentialDescriptors = (operation, value) => {
|
|
65
|
+
if (value === null || value === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
if (!Array.isArray(value))
|
|
68
|
+
throw new Error(`${operation} returned invalid credentials`);
|
|
69
|
+
return value.map((entry) => {
|
|
70
|
+
const descriptor = payloadObject(operation, entry);
|
|
71
|
+
const transports = descriptor.transports ?? [];
|
|
72
|
+
if (!Array.isArray(transports) || !transports.every((t) => typeof t === 'string')) {
|
|
73
|
+
throw new Error(`${operation} returned invalid credential transports`);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
id: payloadString(operation, descriptor, 'id'),
|
|
77
|
+
transports: transports
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
const requireJsonObject = (value, field) => {
|
|
82
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
83
|
+
fail('BAD_USER_INPUT', `${field} must be a WebAuthn credential JSON object`);
|
|
84
|
+
}
|
|
85
|
+
if (JSON.stringify(value).length > MAX_RESPONSE_BYTES) {
|
|
86
|
+
fail('BAD_USER_INPUT', `${field} is too large`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
};
|
|
90
|
+
const challengeMatches = async (tx, settings, challengeName, challenge, challengeId) => {
|
|
91
|
+
const { rows } = await tx.query({
|
|
92
|
+
text: `SELECT ${procedure(settings, 'webauthn_challenge_matches')}($1, $2, $3::uuid) AS matches`,
|
|
93
|
+
values: [challengeName, challenge, challengeId]
|
|
94
|
+
});
|
|
95
|
+
return rows[0]?.matches === true;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* SimpleWebAuthn reports a response that fails verification by throwing; give
|
|
99
|
+
* that a public code. A database error from the challenge lookup inside it
|
|
100
|
+
* keeps its own.
|
|
101
|
+
*/
|
|
102
|
+
const verifying = async (verify) => {
|
|
103
|
+
try {
|
|
104
|
+
return await verify();
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
if (typeof err?.code === 'string' && err.severity)
|
|
108
|
+
throw err;
|
|
109
|
+
return fail('WEBAUTHN_VERIFICATION_FAILED', err?.message ?? String(err));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const userVerification = (settings) => settings.requireUserVerification ? 'required' : 'preferred';
|
|
113
|
+
const lookupCurrentUser = async (tx, req) => {
|
|
114
|
+
const surface = await req?.constructive?.useModule('authSurface');
|
|
115
|
+
if (!surface)
|
|
116
|
+
return { username: null, displayName: null };
|
|
117
|
+
const { rows } = await tx.query({
|
|
118
|
+
text: `SELECT username, display_name FROM ${QuoteUtils.quoteQualifiedIdentifier(surface.publicSchema, 'current_user')}()`
|
|
119
|
+
});
|
|
120
|
+
const row = rows[0];
|
|
121
|
+
return {
|
|
122
|
+
username: typeof row?.username === 'string' ? row.username : null,
|
|
123
|
+
displayName: typeof row?.display_name === 'string' ? row.display_name : null
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
const resolveArgs = (fieldArgs) => object({
|
|
127
|
+
input: fieldArgs.getRaw('input'),
|
|
128
|
+
withPgClient: grafastContext().get('withPgClient'),
|
|
129
|
+
pgSettings: grafastContext().get('pgSettings'),
|
|
130
|
+
webauthn: grafastContext().get(CONTEXT_KEY)
|
|
131
|
+
});
|
|
132
|
+
export const WebauthnPlugin = {
|
|
133
|
+
...extendSchema(() => ({
|
|
134
|
+
typeDefs: gql `
|
|
135
|
+
input WebauthnRegisterBeginInput {
|
|
136
|
+
clientMutationId: String
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type WebauthnRegisterBeginPayload {
|
|
140
|
+
"""PublicKeyCredentialCreationOptionsJSON for navigator.credentials.create()."""
|
|
141
|
+
options: JSON!
|
|
142
|
+
"""The user handle to echo back to webauthnRegisterFinish."""
|
|
143
|
+
webauthnUserId: String!
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
input WebauthnRegisterFinishInput {
|
|
147
|
+
"""The browser's RegistrationResponseJSON."""
|
|
148
|
+
response: JSON!
|
|
149
|
+
webauthnUserId: String!
|
|
150
|
+
"""A label for the passkey."""
|
|
151
|
+
name: String
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type WebauthnRegisterFinishPayload {
|
|
155
|
+
credentialId: String!
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
input WebauthnSignInBeginInput {
|
|
159
|
+
clientMutationId: String
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type WebauthnSignInBeginPayload {
|
|
163
|
+
"""PublicKeyCredentialRequestOptionsJSON for navigator.credentials.get()."""
|
|
164
|
+
options: JSON!
|
|
165
|
+
"""Names the pending challenge; echo it back to webauthnSignInFinish."""
|
|
166
|
+
challengeId: UUID!
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
input WebauthnSignInFinishInput {
|
|
170
|
+
"""The browser's AuthenticationResponseJSON."""
|
|
171
|
+
response: JSON!
|
|
172
|
+
challengeId: UUID!
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type WebauthnSignInFinishPayload {
|
|
176
|
+
accessToken: String!
|
|
177
|
+
accessTokenExpiresAt: Datetime!
|
|
178
|
+
userId: UUID!
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
extend type Mutation {
|
|
182
|
+
"""Begin registering a passkey for the signed-in user."""
|
|
183
|
+
webauthnRegisterBegin(input: WebauthnRegisterBeginInput): WebauthnRegisterBeginPayload
|
|
184
|
+
"""Verify the browser's registration response and store the passkey."""
|
|
185
|
+
webauthnRegisterFinish(input: WebauthnRegisterFinishInput!): WebauthnRegisterFinishPayload
|
|
186
|
+
"""Begin a passkey sign-in (discoverable when signed out)."""
|
|
187
|
+
webauthnSignInBegin(input: WebauthnSignInBeginInput): WebauthnSignInBeginPayload
|
|
188
|
+
"""Verify the browser's assertion and mint a session."""
|
|
189
|
+
webauthnSignInFinish(input: WebauthnSignInFinishInput!): WebauthnSignInFinishPayload
|
|
190
|
+
}
|
|
191
|
+
`,
|
|
192
|
+
plans: {
|
|
193
|
+
Mutation: {
|
|
194
|
+
webauthnRegisterBegin(_$mutation, fieldArgs) {
|
|
195
|
+
return lambda(resolveArgs(fieldArgs), async ({ withPgClient, pgSettings, webauthn }) => {
|
|
196
|
+
const settings = requireSettings(webauthn);
|
|
197
|
+
const userId = requireUserId(pgSettings);
|
|
198
|
+
const { registration, user } = await withRequestLane(withPgClient, pgSettings, async (tx) => {
|
|
199
|
+
const { rows } = await tx.query({
|
|
200
|
+
text: `SELECT ${procedure(settings, 'webauthn_begin_registration')}($1::uuid) AS result`,
|
|
201
|
+
values: [userId]
|
|
202
|
+
});
|
|
203
|
+
const payload = payloadObject('webauthn_begin_registration', rows[0]?.result);
|
|
204
|
+
return {
|
|
205
|
+
registration: {
|
|
206
|
+
challenge: payloadString('webauthn_begin_registration', payload, 'challenge'),
|
|
207
|
+
webauthnUserId: payloadString('webauthn_begin_registration', payload, 'webauthn_user_id'),
|
|
208
|
+
excludeCredentials: credentialDescriptors('webauthn_begin_registration', payload.excludeCredentials)
|
|
209
|
+
},
|
|
210
|
+
user: await lookupCurrentUser(tx, webauthn?.req)
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
const authenticatorSelection = {
|
|
214
|
+
residentKey: settings.residentKey,
|
|
215
|
+
userVerification: userVerification(settings)
|
|
216
|
+
};
|
|
217
|
+
const options = await generateRegistrationOptions({
|
|
218
|
+
// The tenant mints the challenge already base64url-encoded; a
|
|
219
|
+
// string challenge would be re-encoded as UTF-8 and the browser
|
|
220
|
+
// would echo a value the tenant never stored.
|
|
221
|
+
challenge: isoBase64URL.toBuffer(registration.challenge),
|
|
222
|
+
rpID: settings.rpId,
|
|
223
|
+
rpName: settings.rpName,
|
|
224
|
+
attestationType: settings.attestationType,
|
|
225
|
+
authenticatorSelection,
|
|
226
|
+
excludeCredentials: registration.excludeCredentials,
|
|
227
|
+
timeout: settings.challengeExpirySeconds * 1000,
|
|
228
|
+
userID: new TextEncoder().encode(registration.webauthnUserId),
|
|
229
|
+
userName: user.username ?? userId,
|
|
230
|
+
userDisplayName: user.displayName ?? user.username ?? userId
|
|
231
|
+
});
|
|
232
|
+
return { options, webauthnUserId: registration.webauthnUserId };
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
webauthnRegisterFinish(_$mutation, fieldArgs) {
|
|
236
|
+
return lambda(resolveArgs(fieldArgs), async ({ input, withPgClient, pgSettings, webauthn }) => {
|
|
237
|
+
const settings = requireSettings(webauthn);
|
|
238
|
+
const userId = requireUserId(pgSettings);
|
|
239
|
+
const response = requireJsonObject(input?.response, 'response');
|
|
240
|
+
const webauthnUserId = input?.webauthnUserId;
|
|
241
|
+
const name = input?.name ?? null;
|
|
242
|
+
if (name !== null && name.length > MAX_NAME_LENGTH) {
|
|
243
|
+
fail('BAD_USER_INPUT', `name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
244
|
+
}
|
|
245
|
+
const verified = await withVerifierLane(withPgClient, pgSettings, (tx) => verifying(() => verifyRegistrationResponse({
|
|
246
|
+
response,
|
|
247
|
+
expectedChallenge: (challenge) => challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null),
|
|
248
|
+
expectedOrigin: settings.originAllowlist,
|
|
249
|
+
expectedRPID: settings.rpId,
|
|
250
|
+
requireUserVerification: settings.requireUserVerification
|
|
251
|
+
})));
|
|
252
|
+
if (!verified.verified)
|
|
253
|
+
fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn registration verification failed');
|
|
254
|
+
const { credential, credentialDeviceType, credentialBackedUp } = verified.registrationInfo;
|
|
255
|
+
const credentialId = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
|
|
256
|
+
const { rows } = await tx.query({
|
|
257
|
+
text: `SELECT ${procedure(settings, 'webauthn_finish_registration')}(
|
|
258
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
259
|
+
) AS id`,
|
|
260
|
+
values: [
|
|
261
|
+
credential.id,
|
|
262
|
+
Buffer.from(credential.publicKey),
|
|
263
|
+
credential.counter,
|
|
264
|
+
credential.transports ?? [],
|
|
265
|
+
credentialDeviceType,
|
|
266
|
+
// SimpleWebAuthn reports backup eligibility through the
|
|
267
|
+
// device type; backed-up is the separate current-state flag.
|
|
268
|
+
credentialDeviceType === 'multiDevice',
|
|
269
|
+
credentialBackedUp,
|
|
270
|
+
webauthnUserId,
|
|
271
|
+
userId,
|
|
272
|
+
name
|
|
273
|
+
]
|
|
274
|
+
});
|
|
275
|
+
return payloadString('webauthn_finish_registration', rows[0] ?? {}, 'id');
|
|
276
|
+
});
|
|
277
|
+
return { credentialId };
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
webauthnSignInBegin(_$mutation, fieldArgs) {
|
|
281
|
+
return lambda(resolveArgs(fieldArgs), async ({ withPgClient, pgSettings, webauthn }) => {
|
|
282
|
+
const settings = requireSettings(webauthn);
|
|
283
|
+
const signIn = await withRequestLane(withPgClient, pgSettings, async (tx) => {
|
|
284
|
+
const { rows } = await tx.query({
|
|
285
|
+
text: `SELECT ${procedure(settings, 'webauthn_begin_sign_in')}($1::uuid) AS result`,
|
|
286
|
+
values: [pgSettings?.['jwt.claims.user_id'] ?? null]
|
|
287
|
+
});
|
|
288
|
+
const payload = payloadObject('webauthn_begin_sign_in', rows[0]?.result);
|
|
289
|
+
return {
|
|
290
|
+
challenge: payloadString('webauthn_begin_sign_in', payload, 'challenge'),
|
|
291
|
+
challengeId: payloadString('webauthn_begin_sign_in', payload, 'challengeId'),
|
|
292
|
+
allowCredentials: credentialDescriptors('webauthn_begin_sign_in', payload.allowCredentials)
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
const options = await generateAuthenticationOptions({
|
|
296
|
+
challenge: isoBase64URL.toBuffer(signIn.challenge),
|
|
297
|
+
rpID: settings.rpId,
|
|
298
|
+
allowCredentials: signIn.allowCredentials,
|
|
299
|
+
userVerification: userVerification(settings),
|
|
300
|
+
timeout: settings.challengeExpirySeconds * 1000
|
|
301
|
+
});
|
|
302
|
+
return { options, challengeId: signIn.challengeId };
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
webauthnSignInFinish(_$mutation, fieldArgs) {
|
|
306
|
+
return lambda(resolveArgs(fieldArgs), async ({ input, withPgClient, pgSettings, webauthn }) => {
|
|
307
|
+
const settings = requireSettings(webauthn);
|
|
308
|
+
const response = requireJsonObject(input?.response, 'response');
|
|
309
|
+
const challengeId = input?.challengeId;
|
|
310
|
+
const credentialId = response.id;
|
|
311
|
+
if (typeof credentialId !== 'string' || !credentialId) {
|
|
312
|
+
fail('BAD_USER_INPUT', 'response.id is required');
|
|
313
|
+
}
|
|
314
|
+
// One transaction: the challenge read, the counter check and the
|
|
315
|
+
// session mint see the same state, and a refusal undoes them all.
|
|
316
|
+
const row = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
|
|
317
|
+
const { rows: credentialRows } = await tx.query({
|
|
318
|
+
text: `SELECT public_key, sign_count, transports
|
|
319
|
+
FROM ${procedure(settings, 'webauthn_credential_for_assertion')}($1, $2::uuid)`,
|
|
320
|
+
values: [credentialId, challengeId]
|
|
321
|
+
});
|
|
322
|
+
const stored = credentialRows[0];
|
|
323
|
+
if (!stored || !(stored.public_key instanceof Uint8Array)) {
|
|
324
|
+
throw new Error('webauthn_credential_for_assertion returned no public key');
|
|
325
|
+
}
|
|
326
|
+
const publicKey = Uint8Array.from(stored.public_key);
|
|
327
|
+
const transports = stored.transports;
|
|
328
|
+
if (!Array.isArray(transports) || !transports.every((t) => typeof t === 'string')) {
|
|
329
|
+
throw new Error('webauthn_credential_for_assertion returned invalid transports');
|
|
330
|
+
}
|
|
331
|
+
const signCount = Number(stored.sign_count);
|
|
332
|
+
if (!Number.isSafeInteger(signCount) || signCount < 0) {
|
|
333
|
+
throw new Error('webauthn_credential_for_assertion returned invalid sign count');
|
|
334
|
+
}
|
|
335
|
+
const verified = await verifying(() => verifyAuthenticationResponse({
|
|
336
|
+
response,
|
|
337
|
+
expectedChallenge: (challenge) => challengeMatches(tx, settings, SIGN_IN_CHALLENGE, challenge, challengeId),
|
|
338
|
+
expectedOrigin: settings.originAllowlist,
|
|
339
|
+
expectedRPID: settings.rpId,
|
|
340
|
+
credential: {
|
|
341
|
+
id: credentialId,
|
|
342
|
+
publicKey,
|
|
343
|
+
counter: signCount,
|
|
344
|
+
transports: transports
|
|
345
|
+
},
|
|
346
|
+
requireUserVerification: settings.requireUserVerification
|
|
347
|
+
}));
|
|
348
|
+
if (!verified.verified) {
|
|
349
|
+
fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn authentication verification failed');
|
|
350
|
+
}
|
|
351
|
+
const { rows } = await tx.query({
|
|
352
|
+
text: `SELECT user_id, access_token, access_token_expires_at
|
|
353
|
+
FROM ${procedure(settings, 'webauthn_finish_sign_in')}($1, $2, $3, $4, $5::uuid)`,
|
|
354
|
+
values: [
|
|
355
|
+
credentialId,
|
|
356
|
+
verified.authenticationInfo.newCounter,
|
|
357
|
+
verified.authenticationInfo.credentialBackedUp,
|
|
358
|
+
'bearer',
|
|
359
|
+
challengeId
|
|
360
|
+
]
|
|
361
|
+
});
|
|
362
|
+
return rows[0];
|
|
363
|
+
});
|
|
364
|
+
if (!row?.access_token || !row.user_id) {
|
|
365
|
+
throw new Error('webauthn_finish_sign_in minted no session');
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
accessToken: row.access_token,
|
|
369
|
+
accessTokenExpiresAt: row.access_token_expires_at,
|
|
370
|
+
userId: row.user_id
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
})),
|
|
377
|
+
name: 'WebauthnPlugin',
|
|
378
|
+
version: '0.0.0',
|
|
379
|
+
description: 'WebAuthn passkey registration and sign-in mutations, verified in the server.',
|
|
380
|
+
grafast: {
|
|
381
|
+
middleware: {
|
|
382
|
+
prepareArgs(next, event) {
|
|
383
|
+
const req = getExpressRequest(event.args.requestContext);
|
|
384
|
+
// Read per request, not baked into the cached schema: a tenant
|
|
385
|
+
// changing its RP ID or origins takes effect with the next request.
|
|
386
|
+
const ctx = { settings: req?.api?.webauthnSettings, req };
|
|
387
|
+
event.args.contextValue[CONTEXT_KEY] = ctx;
|
|
388
|
+
return next();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
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.
|
|
@@ -5,32 +5,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.notFoundHandler = exports.errorHandler = void 0;
|
|
7
7
|
require("./types");
|
|
8
|
-
const env_1 = require("@pgpmjs/env");
|
|
9
8
|
const logger_1 = require("@pgpmjs/logger");
|
|
10
9
|
const _50x_1 = __importDefault(require("../errors/50x"));
|
|
11
10
|
const _404_message_1 = __importDefault(require("../errors/404-message"));
|
|
12
11
|
const api_errors_1 = require("../errors/api-errors");
|
|
13
12
|
const log = new logger_1.Logger('error-handler');
|
|
14
|
-
const isDevelopment = () => (0, env_1.getNodeEnv)() === 'development';
|
|
15
13
|
const wantsJson = (req) => {
|
|
16
14
|
const accept = req.get('Accept') || '';
|
|
17
15
|
return accept.includes('application/json')
|
|
18
16
|
|| accept.includes('application/graphql-response+json')
|
|
19
17
|
|| Boolean(req.is('json'));
|
|
20
18
|
};
|
|
21
|
-
const sanitizeMessage = (error) => {
|
|
22
|
-
if (isDevelopment())
|
|
23
|
-
return error.message;
|
|
24
|
-
if ((0, api_errors_1.isApiError)(error))
|
|
25
|
-
return error.message;
|
|
26
|
-
if (error.message?.includes('ECONNREFUSED'))
|
|
27
|
-
return 'Service temporarily unavailable';
|
|
28
|
-
if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT'))
|
|
29
|
-
return 'Request timed out';
|
|
30
|
-
if (error.message?.includes('does not exist'))
|
|
31
|
-
return 'The requested resource does not exist';
|
|
32
|
-
return 'An unexpected error occurred';
|
|
33
|
-
};
|
|
34
19
|
const isCsrfError = (err) => {
|
|
35
20
|
const code = err.code;
|
|
36
21
|
return typeof code === 'string' && code.startsWith('CSRF_');
|
|
@@ -40,7 +25,7 @@ const categorizeError = (err) => {
|
|
|
40
25
|
return {
|
|
41
26
|
statusCode: err.statusCode,
|
|
42
27
|
code: err.code,
|
|
43
|
-
message:
|
|
28
|
+
message: err.message,
|
|
44
29
|
logLevel: err.statusCode >= 500 ? 'error' : 'warn',
|
|
45
30
|
};
|
|
46
31
|
}
|
|
@@ -49,12 +34,12 @@ const categorizeError = (err) => {
|
|
|
49
34
|
return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
|
|
50
35
|
}
|
|
51
36
|
if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
|
|
52
|
-
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message:
|
|
37
|
+
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: err.message, logLevel: 'error' };
|
|
53
38
|
}
|
|
54
39
|
if (err.message?.includes('timeout') || err.message?.includes('ETIMEDOUT')) {
|
|
55
|
-
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message:
|
|
40
|
+
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: err.message, logLevel: 'error' };
|
|
56
41
|
}
|
|
57
|
-
return { statusCode: 500, code: 'INTERNAL_ERROR', message:
|
|
42
|
+
return { statusCode: 500, code: 'INTERNAL_ERROR', message: err.message, logLevel: 'error' };
|
|
58
43
|
};
|
|
59
44
|
const sendResponse = (req, res, { statusCode, code, message }) => {
|
|
60
45
|
if (wantsJson(req)) {
|
|
@@ -78,7 +63,7 @@ const logError = (err, req, level) => {
|
|
|
78
63
|
log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context });
|
|
79
64
|
}
|
|
80
65
|
else {
|
|
81
|
-
log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack:
|
|
66
|
+
log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: err.stack, ...context });
|
|
82
67
|
}
|
|
83
68
|
};
|
|
84
69
|
const errorHandler = (err, req, res, _next) => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ErrorContext } from '@constructive-io/errors';
|
|
2
|
+
import { type GraphQLError, type GraphQLFormattedError } from 'graphql';
|
|
3
|
+
/**
|
|
4
|
+
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
5
|
+
*
|
|
6
|
+
* Database errors surface through Grafast without a populated `extensions.code`
|
|
7
|
+
* (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
|
|
8
|
+
* underlying pg error at `originalError`). We parse `originalError` first so we
|
|
9
|
+
* can recover the structured code, then fall back to the GraphQL error itself.
|
|
10
|
+
*/
|
|
11
|
+
export declare const normalizeError: (error: GraphQLError) => {
|
|
12
|
+
code: string | null;
|
|
13
|
+
context: ErrorContext;
|
|
14
|
+
class: "public" | "internal";
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Format every GraphQL error the same way, in every environment. Nothing is
|
|
18
|
+
* masked: the client always receives the real message.
|
|
19
|
+
*
|
|
20
|
+
* 1. Lift the structured code onto `extensions.code`/`class`/`context` from the
|
|
21
|
+
* parsed error, so database errors reach clients with a machine-readable
|
|
22
|
+
* code instead of a bare message with empty `extensions`.
|
|
23
|
+
* 2. A request error graphql-js raised without a code is `BAD_USER_INPUT`.
|
|
24
|
+
* 3. Any other error without a code is `INTERNAL_SERVER_ERROR`.
|
|
25
|
+
* 4. Every internal error (unknown, or registered as internal) carries an
|
|
26
|
+
* `errorId` and is logged under it, so a report can be matched to the log.
|
|
27
|
+
*/
|
|
28
|
+
export declare const formatError: (error: GraphQLError) => GraphQLFormattedError;
|