@constructive-io/graphql-server 5.28.9 → 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/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 +1 -0
- package/esm/plugins/error-events-plugin.js +3 -3
- package/esm/plugins/webauthn-plugin.js +392 -0
- 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 +20 -18
- package/plugins/auth-cookie-plugin.js +1 -0
- package/plugins/error-events-plugin.js +4 -4
- package/plugins/webauthn-plugin.d.ts +3 -0
- package/plugins/webauthn-plugin.js +395 -0
- package/middleware/mask-error.d.ts +0 -27
package/middleware/graphile.js
CHANGED
|
@@ -7,7 +7,6 @@ exports.clearInFlightMap = clearInFlightMap;
|
|
|
7
7
|
require("./types"); // for Request type
|
|
8
8
|
const errors_1 = require("@constructive-io/errors");
|
|
9
9
|
const express_context_1 = require("@constructive-io/express-context");
|
|
10
|
-
const env_1 = require("@pgpmjs/env");
|
|
11
10
|
const logger_1 = require("@pgpmjs/logger");
|
|
12
11
|
const graphile_cache_1 = require("graphile-cache");
|
|
13
12
|
const graphile_function_bindings_1 = require("graphile-function-bindings");
|
|
@@ -20,10 +19,10 @@ const graphql_response_1 = require("../errors/graphql-response");
|
|
|
20
19
|
const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
|
|
21
20
|
const error_events_plugin_1 = require("../plugins/error-events-plugin");
|
|
22
21
|
const request_protection_plugin_1 = require("../plugins/request-protection-plugin");
|
|
22
|
+
const webauthn_plugin_1 = require("../plugins/webauthn-plugin");
|
|
23
|
+
const format_error_1 = require("./format-error");
|
|
23
24
|
const graphile_introspection_1 = require("./graphile-introspection");
|
|
24
|
-
const mask_error_1 = require("./mask-error");
|
|
25
25
|
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
26
|
-
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
27
26
|
// =============================================================================
|
|
28
27
|
// Single-Flight Pattern: In-Flight Tracking
|
|
29
28
|
// =============================================================================
|
|
@@ -63,7 +62,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
63
62
|
* plugin preset. Without settings the default preset is used
|
|
64
63
|
* (everything on except aggregates).
|
|
65
64
|
*/
|
|
66
|
-
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute) => {
|
|
65
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
67
66
|
const introspection = await (0, graphile_introspection_1.makeIntrospectionWiring)(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
68
67
|
const configuredPreset = graphileOptions?.preset ?? {};
|
|
69
68
|
const grafastCachePreset = (0, graphile_settings_1.createGrafastCacheLimitsPreset)(graphileOptions?.grafastCache);
|
|
@@ -83,6 +82,9 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
83
82
|
auth_cookie_plugin_1.AuthCookiePlugin,
|
|
84
83
|
request_protection_plugin_1.RequestProtectionPlugin,
|
|
85
84
|
(0, error_events_plugin_1.createErrorEventsPlugin)(pool),
|
|
85
|
+
// Only when the tenant provisioned the WebAuthn module; the policy
|
|
86
|
+
// itself is read per request from `req.api.webauthnSettings`.
|
|
87
|
+
...(webauthn ? [webauthn_plugin_1.WebauthnPlugin] : []),
|
|
86
88
|
// Only registered when the compute module is provisioned for this
|
|
87
89
|
// database — all schema/table names come from the constructive
|
|
88
90
|
// metaschema (express-context compute module loader); the plugin has
|
|
@@ -109,10 +111,10 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
109
111
|
graphiqlPath: '/graphiql',
|
|
110
112
|
graphiql: true,
|
|
111
113
|
graphiqlOnGraphQLGET: false,
|
|
112
|
-
maskError:
|
|
114
|
+
maskError: format_error_1.formatError
|
|
113
115
|
},
|
|
114
116
|
grafast: {
|
|
115
|
-
explain:
|
|
117
|
+
explain: graphileOptions?.explain === true,
|
|
116
118
|
context: (requestContext) => {
|
|
117
119
|
// In grafserv/express/v4, the request is available at requestContext.expressv4.req
|
|
118
120
|
const req = requestContext?.expressv4?.req;
|
|
@@ -315,7 +317,7 @@ const graphile = (opts) => {
|
|
|
315
317
|
// concurrent requests coalesce while optional plugin modules load.
|
|
316
318
|
const creationPromise = (async () => {
|
|
317
319
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
318
|
-
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute);
|
|
320
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
319
321
|
return (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
320
322
|
cacheKey: key,
|
|
321
323
|
serviceKey: key,
|
|
@@ -349,7 +351,7 @@ const graphile = (opts) => {
|
|
|
349
351
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
350
352
|
if (!res.headersSent) {
|
|
351
353
|
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.INTERNAL_FAILURE({
|
|
352
|
-
details:
|
|
354
|
+
details: e?.message ?? String(e)
|
|
353
355
|
}));
|
|
354
356
|
return;
|
|
355
357
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.29.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
"debug:heap:capture": "node scripts/capture-heap-snapshot.mjs",
|
|
31
31
|
"lint": "eslint . --fix",
|
|
32
32
|
"test": "jest --passWithNoTests",
|
|
33
|
-
"test:watch": "jest --watch"
|
|
34
|
-
"bucket:create": "ts-node src/scripts/create-bucket.ts"
|
|
33
|
+
"test:watch": "jest --watch"
|
|
35
34
|
},
|
|
36
35
|
"keywords": [
|
|
37
36
|
"server",
|
|
@@ -43,19 +42,21 @@
|
|
|
43
42
|
"dependencies": {
|
|
44
43
|
"@agentic-kit/ollama": "2.15.0",
|
|
45
44
|
"@constructive-io/csrf": "^0.30.1",
|
|
46
|
-
"@constructive-io/errors": "^0.
|
|
47
|
-
"@constructive-io/express-context": "^0.33.
|
|
48
|
-
"@constructive-io/graphql-env": "^3.
|
|
49
|
-
"@constructive-io/graphql-types": "^3.
|
|
45
|
+
"@constructive-io/errors": "^0.24.0",
|
|
46
|
+
"@constructive-io/express-context": "^0.33.7",
|
|
47
|
+
"@constructive-io/graphql-env": "^3.35.0",
|
|
48
|
+
"@constructive-io/graphql-types": "^3.34.0",
|
|
50
49
|
"@constructive-io/llm-env": "^0.15.0",
|
|
51
|
-
"@constructive-io/query-builder": "^3.16.
|
|
50
|
+
"@constructive-io/query-builder": "^3.16.8",
|
|
52
51
|
"@constructive-io/s3-utils": "^2.34.0",
|
|
53
52
|
"@constructive-io/url-domains": "^2.31.0",
|
|
54
53
|
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
|
|
55
|
-
"@pgpmjs/env": "^2.
|
|
54
|
+
"@pgpmjs/env": "^2.47.0",
|
|
56
55
|
"@pgpmjs/logger": "^2.26.0",
|
|
57
|
-
"@pgpmjs/server-utils": "^3.29.
|
|
58
|
-
"@pgpmjs/types": "^2.
|
|
56
|
+
"@pgpmjs/server-utils": "^3.29.7",
|
|
57
|
+
"@pgpmjs/types": "^2.57.0",
|
|
58
|
+
"@pgsql/quotes": "^18.2.4",
|
|
59
|
+
"@simplewebauthn/server": "13.3.2",
|
|
59
60
|
"cors": "^2.8.6",
|
|
60
61
|
"deepmerge": "^4.3.1",
|
|
61
62
|
"express": "^5.2.1",
|
|
@@ -64,17 +65,18 @@
|
|
|
64
65
|
"grafserv": "1.0.1",
|
|
65
66
|
"graphile-build": "5.1.1",
|
|
66
67
|
"graphile-build-pg": "5.1.3",
|
|
67
|
-
"graphile-cache": "^4.14.
|
|
68
|
+
"graphile-cache": "^4.14.7",
|
|
68
69
|
"graphile-config": "1.1.0",
|
|
69
|
-
"graphile-function-bindings": "^1.17.
|
|
70
|
-
"graphile-
|
|
71
|
-
"graphile-
|
|
70
|
+
"graphile-function-bindings": "^1.17.8",
|
|
71
|
+
"graphile-plugin-utils": "^1.13.0",
|
|
72
|
+
"graphile-scoped-introspection": "^0.4.8",
|
|
73
|
+
"graphile-settings": "^6.25.0",
|
|
72
74
|
"graphile-utils": "5.0.3",
|
|
73
75
|
"graphql": "16.13.0",
|
|
74
76
|
"graphql-upload": "^13.0.0",
|
|
75
77
|
"lru-cache": "^11.2.7",
|
|
76
78
|
"pg": "^8.21.0",
|
|
77
|
-
"pg-cache": "^3.29.
|
|
79
|
+
"pg-cache": "^3.29.7",
|
|
78
80
|
"pg-env": "^1.32.0",
|
|
79
81
|
"pg-query-context": "^2.31.0",
|
|
80
82
|
"pg-sql2": "5.0.1",
|
|
@@ -91,11 +93,11 @@
|
|
|
91
93
|
"@types/request-ip": "^0.0.41",
|
|
92
94
|
"@types/supertest": "^7.2.1",
|
|
93
95
|
"cookie-parser": "^1.4.7",
|
|
94
|
-
"graphile-test": "5.17.
|
|
96
|
+
"graphile-test": "5.17.8",
|
|
95
97
|
"makage": "^0.8.0",
|
|
96
98
|
"nodemon": "^3.1.14",
|
|
97
99
|
"supertest": "^7.2.2",
|
|
98
100
|
"ts-node": "^10.9.2"
|
|
99
101
|
},
|
|
100
|
-
"gitHead": "
|
|
102
|
+
"gitHead": "1f3501d518e175803a1a3372cf9249922d82bfe5"
|
|
101
103
|
}
|
|
@@ -6,17 +6,17 @@ const logger_1 = require("@pgpmjs/logger");
|
|
|
6
6
|
const graphql_1 = require("graphql");
|
|
7
7
|
const pg_1 = require("pg");
|
|
8
8
|
const pg_query_context_1 = require("pg-query-context");
|
|
9
|
-
const
|
|
9
|
+
const format_error_1 = require("../middleware/format-error");
|
|
10
10
|
const log = new logger_1.Logger('error-events');
|
|
11
11
|
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
12
12
|
/**
|
|
13
13
|
* The first structured, public-classified registry code among the errors.
|
|
14
|
-
* Internal/unknown errors are bugs, not refusals: they are
|
|
15
|
-
*
|
|
14
|
+
* Internal/unknown errors are bugs, not refusals: they are logged by
|
|
15
|
+
* `formatError` and never recorded as tenant events.
|
|
16
16
|
*/
|
|
17
17
|
const refusalCode = (errors) => {
|
|
18
18
|
for (const error of errors ?? []) {
|
|
19
|
-
const { code, class: errorClass } = (0,
|
|
19
|
+
const { code, class: errorClass } = (0, format_error_1.normalizeError)(error);
|
|
20
20
|
if (code && errorClass === 'public')
|
|
21
21
|
return code;
|
|
22
22
|
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebauthnPlugin = void 0;
|
|
4
|
+
require("../middleware/types");
|
|
5
|
+
const quotes_1 = require("@pgsql/quotes");
|
|
6
|
+
const server_1 = require("@simplewebauthn/server");
|
|
7
|
+
const helpers_1 = require("@simplewebauthn/server/helpers");
|
|
8
|
+
const grafast_1 = require("grafast");
|
|
9
|
+
const graphile_plugin_utils_1 = require("graphile-plugin-utils");
|
|
10
|
+
const graphile_utils_1 = require("graphile-utils");
|
|
11
|
+
const CONTEXT_KEY = 'constructiveWebauthn';
|
|
12
|
+
const REGISTER_CHALLENGE = 'webauthn_register_challenge';
|
|
13
|
+
const SIGN_IN_CHALLENGE = 'webauthn_sign_in_challenge';
|
|
14
|
+
const MAX_NAME_LENGTH = 256;
|
|
15
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
16
|
+
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
17
|
+
const fail = (code, message) => {
|
|
18
|
+
const err = new Error(message);
|
|
19
|
+
err.extensions = { code };
|
|
20
|
+
throw err;
|
|
21
|
+
};
|
|
22
|
+
/** Apply the request's settings transaction-locally, optionally overriding its role. */
|
|
23
|
+
const applySettings = async (tx, pgSettings, role) => {
|
|
24
|
+
const entries = Object.entries(pgSettings ?? {}).filter(([key]) => !role || key !== 'role');
|
|
25
|
+
if (role)
|
|
26
|
+
entries.push(['role', role]);
|
|
27
|
+
if (entries.length === 0)
|
|
28
|
+
return;
|
|
29
|
+
await tx.query({
|
|
30
|
+
text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
|
|
31
|
+
values: [JSON.stringify(entries)]
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
const withRequestLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
35
|
+
await applySettings(tx, pgSettings);
|
|
36
|
+
return cb(tx);
|
|
37
|
+
}));
|
|
38
|
+
const withVerifierLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
|
|
39
|
+
await applySettings(tx, pgSettings, graphile_plugin_utils_1.SYSTEM_LANE_ROLE);
|
|
40
|
+
return cb(tx);
|
|
41
|
+
}));
|
|
42
|
+
const procedure = (settings, name) => quotes_1.QuoteUtils.quoteQualifiedIdentifier(settings.schema, name);
|
|
43
|
+
const requireSettings = (ctx) => {
|
|
44
|
+
const settings = ctx?.settings;
|
|
45
|
+
if (!settings)
|
|
46
|
+
throw new Error('WebauthnPlugin is loaded but the request resolved no webauthnSettings');
|
|
47
|
+
return settings;
|
|
48
|
+
};
|
|
49
|
+
const requireUserId = (pgSettings) => {
|
|
50
|
+
const userId = pgSettings?.['jwt.claims.user_id'];
|
|
51
|
+
if (!userId)
|
|
52
|
+
fail('UNAUTHENTICATED', 'Passkey registration requires a signed-in user');
|
|
53
|
+
return userId;
|
|
54
|
+
};
|
|
55
|
+
const payloadObject = (operation, value) => {
|
|
56
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
57
|
+
throw new Error(`${operation} returned no payload`);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
};
|
|
61
|
+
const payloadString = (operation, payload, key) => {
|
|
62
|
+
const value = payload[key];
|
|
63
|
+
if (typeof value !== 'string' || !value)
|
|
64
|
+
throw new Error(`${operation} returned no ${key}`);
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
const credentialDescriptors = (operation, value) => {
|
|
68
|
+
if (value === null || value === undefined)
|
|
69
|
+
return [];
|
|
70
|
+
if (!Array.isArray(value))
|
|
71
|
+
throw new Error(`${operation} returned invalid credentials`);
|
|
72
|
+
return value.map((entry) => {
|
|
73
|
+
const descriptor = payloadObject(operation, entry);
|
|
74
|
+
const transports = descriptor.transports ?? [];
|
|
75
|
+
if (!Array.isArray(transports) || !transports.every((t) => typeof t === 'string')) {
|
|
76
|
+
throw new Error(`${operation} returned invalid credential transports`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
id: payloadString(operation, descriptor, 'id'),
|
|
80
|
+
transports: transports
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const requireJsonObject = (value, field) => {
|
|
85
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
86
|
+
fail('BAD_USER_INPUT', `${field} must be a WebAuthn credential JSON object`);
|
|
87
|
+
}
|
|
88
|
+
if (JSON.stringify(value).length > MAX_RESPONSE_BYTES) {
|
|
89
|
+
fail('BAD_USER_INPUT', `${field} is too large`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
const challengeMatches = async (tx, settings, challengeName, challenge, challengeId) => {
|
|
94
|
+
const { rows } = await tx.query({
|
|
95
|
+
text: `SELECT ${procedure(settings, 'webauthn_challenge_matches')}($1, $2, $3::uuid) AS matches`,
|
|
96
|
+
values: [challengeName, challenge, challengeId]
|
|
97
|
+
});
|
|
98
|
+
return rows[0]?.matches === true;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* SimpleWebAuthn reports a response that fails verification by throwing; give
|
|
102
|
+
* that a public code. A database error from the challenge lookup inside it
|
|
103
|
+
* keeps its own.
|
|
104
|
+
*/
|
|
105
|
+
const verifying = async (verify) => {
|
|
106
|
+
try {
|
|
107
|
+
return await verify();
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (typeof err?.code === 'string' && err.severity)
|
|
111
|
+
throw err;
|
|
112
|
+
return fail('WEBAUTHN_VERIFICATION_FAILED', err?.message ?? String(err));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const userVerification = (settings) => settings.requireUserVerification ? 'required' : 'preferred';
|
|
116
|
+
const lookupCurrentUser = async (tx, req) => {
|
|
117
|
+
const surface = await req?.constructive?.useModule('authSurface');
|
|
118
|
+
if (!surface)
|
|
119
|
+
return { username: null, displayName: null };
|
|
120
|
+
const { rows } = await tx.query({
|
|
121
|
+
text: `SELECT username, display_name FROM ${quotes_1.QuoteUtils.quoteQualifiedIdentifier(surface.publicSchema, 'current_user')}()`
|
|
122
|
+
});
|
|
123
|
+
const row = rows[0];
|
|
124
|
+
return {
|
|
125
|
+
username: typeof row?.username === 'string' ? row.username : null,
|
|
126
|
+
displayName: typeof row?.display_name === 'string' ? row.display_name : null
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
const resolveArgs = (fieldArgs) => (0, grafast_1.object)({
|
|
130
|
+
input: fieldArgs.getRaw('input'),
|
|
131
|
+
withPgClient: (0, grafast_1.context)().get('withPgClient'),
|
|
132
|
+
pgSettings: (0, grafast_1.context)().get('pgSettings'),
|
|
133
|
+
webauthn: (0, grafast_1.context)().get(CONTEXT_KEY)
|
|
134
|
+
});
|
|
135
|
+
exports.WebauthnPlugin = {
|
|
136
|
+
...(0, graphile_utils_1.extendSchema)(() => ({
|
|
137
|
+
typeDefs: (0, graphile_utils_1.gql) `
|
|
138
|
+
input WebauthnRegisterBeginInput {
|
|
139
|
+
clientMutationId: String
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type WebauthnRegisterBeginPayload {
|
|
143
|
+
"""PublicKeyCredentialCreationOptionsJSON for navigator.credentials.create()."""
|
|
144
|
+
options: JSON!
|
|
145
|
+
"""The user handle to echo back to webauthnRegisterFinish."""
|
|
146
|
+
webauthnUserId: String!
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
input WebauthnRegisterFinishInput {
|
|
150
|
+
"""The browser's RegistrationResponseJSON."""
|
|
151
|
+
response: JSON!
|
|
152
|
+
webauthnUserId: String!
|
|
153
|
+
"""A label for the passkey."""
|
|
154
|
+
name: String
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type WebauthnRegisterFinishPayload {
|
|
158
|
+
credentialId: String!
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
input WebauthnSignInBeginInput {
|
|
162
|
+
clientMutationId: String
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type WebauthnSignInBeginPayload {
|
|
166
|
+
"""PublicKeyCredentialRequestOptionsJSON for navigator.credentials.get()."""
|
|
167
|
+
options: JSON!
|
|
168
|
+
"""Names the pending challenge; echo it back to webauthnSignInFinish."""
|
|
169
|
+
challengeId: UUID!
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
input WebauthnSignInFinishInput {
|
|
173
|
+
"""The browser's AuthenticationResponseJSON."""
|
|
174
|
+
response: JSON!
|
|
175
|
+
challengeId: UUID!
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type WebauthnSignInFinishPayload {
|
|
179
|
+
accessToken: String!
|
|
180
|
+
accessTokenExpiresAt: Datetime!
|
|
181
|
+
userId: UUID!
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
extend type Mutation {
|
|
185
|
+
"""Begin registering a passkey for the signed-in user."""
|
|
186
|
+
webauthnRegisterBegin(input: WebauthnRegisterBeginInput): WebauthnRegisterBeginPayload
|
|
187
|
+
"""Verify the browser's registration response and store the passkey."""
|
|
188
|
+
webauthnRegisterFinish(input: WebauthnRegisterFinishInput!): WebauthnRegisterFinishPayload
|
|
189
|
+
"""Begin a passkey sign-in (discoverable when signed out)."""
|
|
190
|
+
webauthnSignInBegin(input: WebauthnSignInBeginInput): WebauthnSignInBeginPayload
|
|
191
|
+
"""Verify the browser's assertion and mint a session."""
|
|
192
|
+
webauthnSignInFinish(input: WebauthnSignInFinishInput!): WebauthnSignInFinishPayload
|
|
193
|
+
}
|
|
194
|
+
`,
|
|
195
|
+
plans: {
|
|
196
|
+
Mutation: {
|
|
197
|
+
webauthnRegisterBegin(_$mutation, fieldArgs) {
|
|
198
|
+
return (0, grafast_1.lambda)(resolveArgs(fieldArgs), async ({ withPgClient, pgSettings, webauthn }) => {
|
|
199
|
+
const settings = requireSettings(webauthn);
|
|
200
|
+
const userId = requireUserId(pgSettings);
|
|
201
|
+
const { registration, user } = await withRequestLane(withPgClient, pgSettings, async (tx) => {
|
|
202
|
+
const { rows } = await tx.query({
|
|
203
|
+
text: `SELECT ${procedure(settings, 'webauthn_begin_registration')}($1::uuid) AS result`,
|
|
204
|
+
values: [userId]
|
|
205
|
+
});
|
|
206
|
+
const payload = payloadObject('webauthn_begin_registration', rows[0]?.result);
|
|
207
|
+
return {
|
|
208
|
+
registration: {
|
|
209
|
+
challenge: payloadString('webauthn_begin_registration', payload, 'challenge'),
|
|
210
|
+
webauthnUserId: payloadString('webauthn_begin_registration', payload, 'webauthn_user_id'),
|
|
211
|
+
excludeCredentials: credentialDescriptors('webauthn_begin_registration', payload.excludeCredentials)
|
|
212
|
+
},
|
|
213
|
+
user: await lookupCurrentUser(tx, webauthn?.req)
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
const authenticatorSelection = {
|
|
217
|
+
residentKey: settings.residentKey,
|
|
218
|
+
userVerification: userVerification(settings)
|
|
219
|
+
};
|
|
220
|
+
const options = await (0, server_1.generateRegistrationOptions)({
|
|
221
|
+
// The tenant mints the challenge already base64url-encoded; a
|
|
222
|
+
// string challenge would be re-encoded as UTF-8 and the browser
|
|
223
|
+
// would echo a value the tenant never stored.
|
|
224
|
+
challenge: helpers_1.isoBase64URL.toBuffer(registration.challenge),
|
|
225
|
+
rpID: settings.rpId,
|
|
226
|
+
rpName: settings.rpName,
|
|
227
|
+
attestationType: settings.attestationType,
|
|
228
|
+
authenticatorSelection,
|
|
229
|
+
excludeCredentials: registration.excludeCredentials,
|
|
230
|
+
timeout: settings.challengeExpirySeconds * 1000,
|
|
231
|
+
userID: new TextEncoder().encode(registration.webauthnUserId),
|
|
232
|
+
userName: user.username ?? userId,
|
|
233
|
+
userDisplayName: user.displayName ?? user.username ?? userId
|
|
234
|
+
});
|
|
235
|
+
return { options, webauthnUserId: registration.webauthnUserId };
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
webauthnRegisterFinish(_$mutation, fieldArgs) {
|
|
239
|
+
return (0, grafast_1.lambda)(resolveArgs(fieldArgs), async ({ input, withPgClient, pgSettings, webauthn }) => {
|
|
240
|
+
const settings = requireSettings(webauthn);
|
|
241
|
+
const userId = requireUserId(pgSettings);
|
|
242
|
+
const response = requireJsonObject(input?.response, 'response');
|
|
243
|
+
const webauthnUserId = input?.webauthnUserId;
|
|
244
|
+
const name = input?.name ?? null;
|
|
245
|
+
if (name !== null && name.length > MAX_NAME_LENGTH) {
|
|
246
|
+
fail('BAD_USER_INPUT', `name must be at most ${MAX_NAME_LENGTH} characters`);
|
|
247
|
+
}
|
|
248
|
+
const verified = await withVerifierLane(withPgClient, pgSettings, (tx) => verifying(() => (0, server_1.verifyRegistrationResponse)({
|
|
249
|
+
response,
|
|
250
|
+
expectedChallenge: (challenge) => challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null),
|
|
251
|
+
expectedOrigin: settings.originAllowlist,
|
|
252
|
+
expectedRPID: settings.rpId,
|
|
253
|
+
requireUserVerification: settings.requireUserVerification
|
|
254
|
+
})));
|
|
255
|
+
if (!verified.verified)
|
|
256
|
+
fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn registration verification failed');
|
|
257
|
+
const { credential, credentialDeviceType, credentialBackedUp } = verified.registrationInfo;
|
|
258
|
+
const credentialId = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
|
|
259
|
+
const { rows } = await tx.query({
|
|
260
|
+
text: `SELECT ${procedure(settings, 'webauthn_finish_registration')}(
|
|
261
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
|
262
|
+
) AS id`,
|
|
263
|
+
values: [
|
|
264
|
+
credential.id,
|
|
265
|
+
Buffer.from(credential.publicKey),
|
|
266
|
+
credential.counter,
|
|
267
|
+
credential.transports ?? [],
|
|
268
|
+
credentialDeviceType,
|
|
269
|
+
// SimpleWebAuthn reports backup eligibility through the
|
|
270
|
+
// device type; backed-up is the separate current-state flag.
|
|
271
|
+
credentialDeviceType === 'multiDevice',
|
|
272
|
+
credentialBackedUp,
|
|
273
|
+
webauthnUserId,
|
|
274
|
+
userId,
|
|
275
|
+
name
|
|
276
|
+
]
|
|
277
|
+
});
|
|
278
|
+
return payloadString('webauthn_finish_registration', rows[0] ?? {}, 'id');
|
|
279
|
+
});
|
|
280
|
+
return { credentialId };
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
webauthnSignInBegin(_$mutation, fieldArgs) {
|
|
284
|
+
return (0, grafast_1.lambda)(resolveArgs(fieldArgs), async ({ withPgClient, pgSettings, webauthn }) => {
|
|
285
|
+
const settings = requireSettings(webauthn);
|
|
286
|
+
const signIn = await withRequestLane(withPgClient, pgSettings, async (tx) => {
|
|
287
|
+
const { rows } = await tx.query({
|
|
288
|
+
text: `SELECT ${procedure(settings, 'webauthn_begin_sign_in')}($1::uuid) AS result`,
|
|
289
|
+
values: [pgSettings?.['jwt.claims.user_id'] ?? null]
|
|
290
|
+
});
|
|
291
|
+
const payload = payloadObject('webauthn_begin_sign_in', rows[0]?.result);
|
|
292
|
+
return {
|
|
293
|
+
challenge: payloadString('webauthn_begin_sign_in', payload, 'challenge'),
|
|
294
|
+
challengeId: payloadString('webauthn_begin_sign_in', payload, 'challengeId'),
|
|
295
|
+
allowCredentials: credentialDescriptors('webauthn_begin_sign_in', payload.allowCredentials)
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
const options = await (0, server_1.generateAuthenticationOptions)({
|
|
299
|
+
challenge: helpers_1.isoBase64URL.toBuffer(signIn.challenge),
|
|
300
|
+
rpID: settings.rpId,
|
|
301
|
+
allowCredentials: signIn.allowCredentials,
|
|
302
|
+
userVerification: userVerification(settings),
|
|
303
|
+
timeout: settings.challengeExpirySeconds * 1000
|
|
304
|
+
});
|
|
305
|
+
return { options, challengeId: signIn.challengeId };
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
webauthnSignInFinish(_$mutation, fieldArgs) {
|
|
309
|
+
return (0, grafast_1.lambda)(resolveArgs(fieldArgs), async ({ input, withPgClient, pgSettings, webauthn }) => {
|
|
310
|
+
const settings = requireSettings(webauthn);
|
|
311
|
+
const response = requireJsonObject(input?.response, 'response');
|
|
312
|
+
const challengeId = input?.challengeId;
|
|
313
|
+
const credentialId = response.id;
|
|
314
|
+
if (typeof credentialId !== 'string' || !credentialId) {
|
|
315
|
+
fail('BAD_USER_INPUT', 'response.id is required');
|
|
316
|
+
}
|
|
317
|
+
// One transaction: the challenge read, the counter check and the
|
|
318
|
+
// session mint see the same state, and a refusal undoes them all.
|
|
319
|
+
const row = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
|
|
320
|
+
const { rows: credentialRows } = await tx.query({
|
|
321
|
+
text: `SELECT public_key, sign_count, transports
|
|
322
|
+
FROM ${procedure(settings, 'webauthn_credential_for_assertion')}($1, $2::uuid)`,
|
|
323
|
+
values: [credentialId, challengeId]
|
|
324
|
+
});
|
|
325
|
+
const stored = credentialRows[0];
|
|
326
|
+
if (!stored || !(stored.public_key instanceof Uint8Array)) {
|
|
327
|
+
throw new Error('webauthn_credential_for_assertion returned no public key');
|
|
328
|
+
}
|
|
329
|
+
const publicKey = Uint8Array.from(stored.public_key);
|
|
330
|
+
const transports = stored.transports;
|
|
331
|
+
if (!Array.isArray(transports) || !transports.every((t) => typeof t === 'string')) {
|
|
332
|
+
throw new Error('webauthn_credential_for_assertion returned invalid transports');
|
|
333
|
+
}
|
|
334
|
+
const signCount = Number(stored.sign_count);
|
|
335
|
+
if (!Number.isSafeInteger(signCount) || signCount < 0) {
|
|
336
|
+
throw new Error('webauthn_credential_for_assertion returned invalid sign count');
|
|
337
|
+
}
|
|
338
|
+
const verified = await verifying(() => (0, server_1.verifyAuthenticationResponse)({
|
|
339
|
+
response,
|
|
340
|
+
expectedChallenge: (challenge) => challengeMatches(tx, settings, SIGN_IN_CHALLENGE, challenge, challengeId),
|
|
341
|
+
expectedOrigin: settings.originAllowlist,
|
|
342
|
+
expectedRPID: settings.rpId,
|
|
343
|
+
credential: {
|
|
344
|
+
id: credentialId,
|
|
345
|
+
publicKey,
|
|
346
|
+
counter: signCount,
|
|
347
|
+
transports: transports
|
|
348
|
+
},
|
|
349
|
+
requireUserVerification: settings.requireUserVerification
|
|
350
|
+
}));
|
|
351
|
+
if (!verified.verified) {
|
|
352
|
+
fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn authentication verification failed');
|
|
353
|
+
}
|
|
354
|
+
const { rows } = await tx.query({
|
|
355
|
+
text: `SELECT user_id, access_token, access_token_expires_at
|
|
356
|
+
FROM ${procedure(settings, 'webauthn_finish_sign_in')}($1, $2, $3, $4, $5::uuid)`,
|
|
357
|
+
values: [
|
|
358
|
+
credentialId,
|
|
359
|
+
verified.authenticationInfo.newCounter,
|
|
360
|
+
verified.authenticationInfo.credentialBackedUp,
|
|
361
|
+
'bearer',
|
|
362
|
+
challengeId
|
|
363
|
+
]
|
|
364
|
+
});
|
|
365
|
+
return rows[0];
|
|
366
|
+
});
|
|
367
|
+
if (!row?.access_token || !row.user_id) {
|
|
368
|
+
throw new Error('webauthn_finish_sign_in minted no session');
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
accessToken: row.access_token,
|
|
372
|
+
accessTokenExpiresAt: row.access_token_expires_at,
|
|
373
|
+
userId: row.user_id
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
})),
|
|
380
|
+
name: 'WebauthnPlugin',
|
|
381
|
+
version: '0.0.0',
|
|
382
|
+
description: 'WebAuthn passkey registration and sign-in mutations, verified in the server.',
|
|
383
|
+
grafast: {
|
|
384
|
+
middleware: {
|
|
385
|
+
prepareArgs(next, event) {
|
|
386
|
+
const req = getExpressRequest(event.args.requestContext);
|
|
387
|
+
// Read per request, not baked into the cached schema: a tenant
|
|
388
|
+
// changing its RP ID or origins takes effect with the next request.
|
|
389
|
+
const ctx = { settings: req?.api?.webauthnSettings, req };
|
|
390
|
+
event.args.contextValue[CONTEXT_KEY] = ctx;
|
|
391
|
+
return next();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
* Production-aware error handling backed by `@constructive-io/errors`.
|
|
18
|
-
*
|
|
19
|
-
* 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
|
|
20
|
-
* always receive a machine-readable code (fixing the gap where database
|
|
21
|
-
* errors reached clients as a bare message with empty `extensions`).
|
|
22
|
-
* 2. Surface public (registered/allowlisted) errors as-is.
|
|
23
|
-
* 3. In development, pass everything through (enriched) for debugging.
|
|
24
|
-
* 4. In production, mask internal/unknown errors behind a reference ID and log
|
|
25
|
-
* the original.
|
|
26
|
-
*/
|
|
27
|
-
export declare const maskError: (error: GraphQLError) => GraphQLError | GraphQLFormattedError;
|