fastify-txstate 3.6.9 → 4.0.1

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/lib/server.js ADDED
@@ -0,0 +1,441 @@
1
+ import { Ajv } from 'ajv';
2
+ import swagger from '@fastify/swagger';
3
+ import swaggerUI from '@fastify/swagger-ui';
4
+ import { validatedResponse } from '@txstate-mws/fastify-shared';
5
+ import ajvErrors from 'ajv-errors';
6
+ import ajvFormats from 'ajv-formats';
7
+ import { fastify } from 'fastify';
8
+ import fs from 'node:fs';
9
+ import pino from 'pino';
10
+ import http from 'node:http';
11
+ import { Writable } from 'node:stream';
12
+ import { clone, destroyNulls, isBlank, isNotBlank, omit, set, sleep, stringifyDates, toArray } from 'txstate-utils';
13
+ import { HttpError, ValidationError, ValidationErrors, fstValidationToMessage } from "./error.js";
14
+ /**
15
+ * Get the base URL for this API. Uses PUBLIC_URL if set, otherwise derives
16
+ * from the request's protocol and hostname.
17
+ */
18
+ export function apiBaseUrl(req) {
19
+ if (isNotBlank(process.env.PUBLIC_URL))
20
+ return process.env.PUBLIC_URL.replace(/\/$/v, '');
21
+ return req.protocol + '://' + req.hostname;
22
+ }
23
+ /**
24
+ * Get the base URL for the UI that this API serves. Uses UI_URL if set,
25
+ * otherwise assumes the API lives at a subpath (e.g. /api) and the UI is
26
+ * one level up. Falls back to the API base URL if there's no parent path.
27
+ */
28
+ export function uiBaseUrl(req) {
29
+ if (isNotBlank(process.env.UI_URL))
30
+ return process.env.UI_URL.replace(/\/$/v, '');
31
+ const base = apiBaseUrl(req);
32
+ return new URL('..', base + '/').toString().replace(/\/$/v, '');
33
+ }
34
+ /* eslint-disable no-console -- devLogger intentionally writes to console */
35
+ export const devLogger = pino({
36
+ level: 'info'
37
+ }, new Writable({
38
+ write(chunk, _encoding, callback) {
39
+ const obj = JSON.parse(String(chunk));
40
+ if (obj.req)
41
+ console.info(`${obj.req.method} ${obj.req.url}`);
42
+ else if (obj.res)
43
+ console.info(`${obj.res.statusCode} - ${obj.responseTime}`);
44
+ else if (obj.err)
45
+ console.error(obj.err);
46
+ else
47
+ console.info(obj.msg || obj);
48
+ callback();
49
+ }
50
+ }));
51
+ /* eslint-enable no-console */
52
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call -- pino serializer params are typed as any */
53
+ export const prodLogger = pino({
54
+ level: 'info',
55
+ serializers: {
56
+ req(req) {
57
+ return {
58
+ method: req.method,
59
+ url: req.url.replace(/(?<param>token|unifiedJwt)=[\w.]+/iv, '$<param>=redacted'),
60
+ remoteAddress: req.ip,
61
+ traceparent: req.headers.traceparent
62
+ };
63
+ },
64
+ res(res) {
65
+ return {
66
+ statusCode: res.statusCode,
67
+ url: res.request?.url.replace(/(?<param>token|unifiedJwt)=[\w.]+/iv, '$<param>=redacted'),
68
+ length: Number(toArray(res.getHeader?.('content-length'))[0]),
69
+ ...omit(res.extraLogInfo, 'auth'),
70
+ auth: omit(res.request?.auth ?? res.extraLogInfo?.auth ?? {}, 'token', 'accessToken', 'issuerConfig')
71
+ };
72
+ }
73
+ }
74
+ });
75
+ export class OriginChecker {
76
+ validOrigins = {};
77
+ validOriginHosts = {};
78
+ validOriginSuffixes = new Set();
79
+ setValidOrigins(origins) {
80
+ this.validOrigins = origins.reduce((acc, origin) => ({ ...acc, [origin]: true }), {});
81
+ }
82
+ setValidOriginHosts(hosts) {
83
+ this.validOriginHosts = hosts.reduce((acc, host) => ({ ...acc, [host]: true }), {});
84
+ }
85
+ setValidOriginSuffixes(suffixes) {
86
+ this.validOriginSuffixes.clear();
87
+ for (const s of suffixes)
88
+ this.validOriginSuffixes.add(s.replace(/^\./v, ''));
89
+ }
90
+ check(hostname, requestHostname) {
91
+ try {
92
+ const parsed = new URL(hostname.includes('://') ? hostname : 'https://' + hostname);
93
+ if (this.validOrigins[parsed.origin])
94
+ return true;
95
+ if (requestHostname && parsed.hostname === requestHostname)
96
+ return true;
97
+ if (this.validOriginHosts[parsed.hostname])
98
+ return true;
99
+ const parts = parsed.hostname.split('.');
100
+ for (let i = 0; i < parts.length; i++) {
101
+ if (this.validOriginSuffixes.has(parts.slice(i).join('.')))
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ }
111
+ export default class Server {
112
+ config;
113
+ https = false;
114
+ errorHandlers = [];
115
+ healthMessage;
116
+ healthCallback;
117
+ shuttingDown = false;
118
+ sigHandler;
119
+ originChecker = new OriginChecker();
120
+ swaggerEndpoint;
121
+ app;
122
+ constructor(config = {}) {
123
+ this.config = config;
124
+ try {
125
+ const key = fs.readFileSync('/securekeys/private.key');
126
+ const cert = fs.readFileSync('/securekeys/cert.pem');
127
+ config.https = {
128
+ ...config.https,
129
+ allowHTTP1: true,
130
+ key,
131
+ cert,
132
+ minVersion: 'TLSv1.2'
133
+ };
134
+ config.http2 = true;
135
+ this.https = true;
136
+ }
137
+ catch (e) {
138
+ this.https = false;
139
+ delete config.https;
140
+ }
141
+ if (typeof config.logger === 'undefined' && !config.loggerInstance) {
142
+ config.loggerInstance = process.env.NODE_ENV === 'development'
143
+ ? devLogger
144
+ : prodLogger;
145
+ }
146
+ if (process.env.TRUST_PROXY != null) {
147
+ if (['true', '1'].includes(process.env.TRUST_PROXY))
148
+ config.trustProxy = true;
149
+ else
150
+ config.trustProxy = process.env.TRUST_PROXY;
151
+ }
152
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- ajvFormats cast is required by tsc even though eslint thinks otherwise
153
+ config.ajv = { ...config.ajv, mode: undefined, plugins: [...(config.ajv?.plugins ?? []), ajvErrors, [ajvFormats, { mode: 'fast' }]], customOptions: { ...config.ajv?.customOptions, allErrors: true, strictSchema: false, coerceTypes: true } };
154
+ this.healthCallback = config.checkHealth;
155
+ this.app = fastify(config);
156
+ this.app.addHook('onRoute', route => {
157
+ if (!route.schema?.body)
158
+ return;
159
+ const missingResponse = route.schema.response == null;
160
+ const response400 = set(validatedResponse.properties.messages, 'description', 'Basic validation failure. This means that the UI provided input that failed validation as defined in the openapi specification published by the API. The UI is at fault and should be re-coded to avoid sending invalid data.');
161
+ let newSchema = set(route.schema ?? {}, 'response.400', response400);
162
+ const response422 = set(validatedResponse, 'description', 'Validation failure. This means that the user provided an invalid object. The user should be shown their error so that they can correct it.');
163
+ newSchema = set(newSchema, 'response.422', response422);
164
+ if (missingResponse) {
165
+ newSchema.response['200'] = {
166
+ description: 'Success. Return type has not been specified.',
167
+ type: 'object'
168
+ };
169
+ }
170
+ route.schema = newSchema;
171
+ });
172
+ this.app.addHook('preValidation', (req, res, done) => {
173
+ if (req.body != null && req.routeOptions.schema?.body)
174
+ destroyNulls(req.body);
175
+ done();
176
+ });
177
+ // use Ajv to validate responses instead of @fastify/json-fast-stringify since ajv does
178
+ // a better job with recursive types and we don't want to have different behavior between
179
+ // input and output validation
180
+ const ajv = new Ajv(config.ajv.customOptions);
181
+ for (const pluginConfig of config.ajv.plugins ?? []) {
182
+ const [plugin, opts] = toArray(pluginConfig);
183
+ plugin(ajv, opts); // eslint-disable-line @typescript-eslint/no-unsafe-call -- plugin type comes from fastify's ajv config
184
+ }
185
+ this.app.setSerializerCompiler(route => {
186
+ const schema = route.schema;
187
+ const validate = schema == null ? ajv.compile({ type: 'object' }) : ajv.compile(schema); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- schema can be undefined at runtime
188
+ return data => {
189
+ /**
190
+ * Ajv unfortunately treats optional properties as non-nullable, so they're allowed to
191
+ * be undefined but not allowed to be null. Worse, with `coerceTypes`, null will be converted
192
+ * to empty string or 0 or false. This is silly behavior, so we're converting all nulls to
193
+ * undefined before we validate.
194
+ */
195
+ if (schema != null)
196
+ destroyNulls(stringifyDates(data)); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- schema can be undefined at runtime
197
+ if (!validate(data))
198
+ throw new Error('Output validation failed. ' + validate.errors?.[0].instancePath + ': ' + validate.errors?.[0].message);
199
+ return JSON.stringify(data);
200
+ };
201
+ });
202
+ this.app.addHook('onRequest', (req, res, done) => {
203
+ res.extraLogInfo = {};
204
+ req.originChecker = this.originChecker;
205
+ done();
206
+ });
207
+ if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
208
+ this.originChecker.setValidOrigins([...(config.validOrigins ?? []), ...(process.env.VALID_ORIGINS?.split(',') ?? [])]);
209
+ this.originChecker.setValidOriginHosts([...(config.validOriginHosts ?? []), ...(process.env.VALID_ORIGIN_HOSTS?.split(',') ?? [])]);
210
+ this.originChecker.setValidOriginSuffixes([...(config.validOriginSuffixes ?? []), ...(process.env.VALID_ORIGIN_SUFFIXES?.split(',') ?? [])]);
211
+ this.app.addHook('onRequest', async (req, res) => {
212
+ if (!req.headers.origin)
213
+ return;
214
+ const passed = (req.headers.origin === 'null'
215
+ ? process.env.NODE_ENV === 'development'
216
+ : this.originChecker.check(req.headers.origin, req.hostname)) || !!config.checkOrigin?.(req);
217
+ if (!passed) {
218
+ await res.status(403).send('Origin check failed. Suspected XSRF attack.');
219
+ return await res;
220
+ }
221
+ else {
222
+ void res.header('Access-Control-Allow-Origin', req.headers.origin);
223
+ void res.header('Access-Control-Max-Age', '600'); // ask browser to skip pre-flights for 10 minutes after a yes
224
+ if (req.headers['access-control-request-method'])
225
+ void res.header('access-control-allow-methods', req.headers['access-control-request-method']);
226
+ if (req.headers['access-control-request-headers'])
227
+ void res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
228
+ }
229
+ });
230
+ this.app.options('*', async (req, res) => {
231
+ await res.send();
232
+ });
233
+ }
234
+ if (config.authenticate) {
235
+ const authenticatedMethods = {
236
+ GET: true,
237
+ POST: true,
238
+ PUT: true,
239
+ PATCH: true,
240
+ DELETE: true
241
+ };
242
+ this.app.addHook('onRequest', async (req, res) => {
243
+ if (!authenticatedMethods[req.method] || isBlank(req.routeOptions.url) || req.routeOptions.url === '/health' || req.routeOptions.url === '/.uaService' || (this.swaggerEndpoint && req.routeOptions.url.startsWith(this.swaggerEndpoint)))
244
+ return;
245
+ try {
246
+ req.auth = await config.authenticate(req);
247
+ }
248
+ catch (e) {
249
+ await res.status(401).send('Failed to authenticate.');
250
+ return await res;
251
+ }
252
+ });
253
+ }
254
+ this.app.addHook('onSend', this.https && process.env.NODE_ENV !== 'development'
255
+ ? async (_, resp) => {
256
+ void resp.removeHeader('X-Powered-By');
257
+ void resp.header('Strict-Transport-Security', 'max-age=31536000');
258
+ if (resp.getHeader('content-type') === 'text/html')
259
+ void resp.type('text/html; charset=utf-8');
260
+ }
261
+ : async (_, resp) => {
262
+ void resp.removeHeader('X-Powered-By');
263
+ if (resp.getHeader('content-type') === 'text/html')
264
+ void resp.type('text/html; charset=utf-8');
265
+ });
266
+ this.app.setNotFoundHandler((req, res) => { void res.status(404).send('Not Found.'); });
267
+ this.app.setErrorHandler(async (err, req, res) => {
268
+ req.log.warn(err);
269
+ for (const errorHandler of this.errorHandlers) {
270
+ if (!res.sent)
271
+ await errorHandler(err, req, res);
272
+ }
273
+ if (!res.sent) {
274
+ if (err instanceof ValidationError) {
275
+ await res.status(err.statusCode).send({ success: false, messages: [{ message: err.message, path: err.path, type: err.type ?? 'error' }] });
276
+ }
277
+ else if (err instanceof ValidationErrors) {
278
+ await res.status(err.statusCode).send({ success: false, messages: err.errors });
279
+ }
280
+ else if (err instanceof HttpError) {
281
+ await res.status(err.statusCode).send(err.message);
282
+ }
283
+ else if (err.code === 'FST_ERR_VALIDATION') {
284
+ const developerErrors = [];
285
+ const userErrors = [];
286
+ for (const v of err.validation ?? []) {
287
+ if (v.keyword === 'errorMessage') {
288
+ for (const ov of v.params.errors) {
289
+ if (['type', 'additionalProperties', 'minProperties'].includes(ov.keyword))
290
+ developerErrors.push({ ...ov, message: v.message });
291
+ else if (ov.keyword === 'required')
292
+ userErrors.push({ ...ov, message: 'This field is required.' });
293
+ else
294
+ userErrors.push({ ...ov, message: v.message });
295
+ }
296
+ }
297
+ else if (['type', 'additionalProperties', 'minProperties'].includes(v.keyword))
298
+ developerErrors.push(v);
299
+ else if (v.keyword === 'required')
300
+ userErrors.push({ ...v, message: 'This field is required.' });
301
+ else
302
+ userErrors.push(v);
303
+ }
304
+ if (userErrors.length)
305
+ await res.status(422).send({ success: false, messages: userErrors.map(fstValidationToMessage) });
306
+ else
307
+ await res.status(400).send(developerErrors.map(fstValidationToMessage));
308
+ }
309
+ else if (err.statusCode) {
310
+ await res.status(err.statusCode).send(new HttpError(err.statusCode).message);
311
+ }
312
+ else {
313
+ await res.status(500).send('Internal Server Error.');
314
+ }
315
+ }
316
+ });
317
+ this.app.get('/health', { logLevel: 'warn' }, async (req, res) => {
318
+ if (this.shuttingDown) {
319
+ res.log.warn('Returning 503 on /health because we are shutting down/restarting.');
320
+ void res.status(503);
321
+ return 'MAINTENANCE';
322
+ }
323
+ else if (this.healthMessage) {
324
+ res.log.error(this.healthMessage);
325
+ void res.status(500);
326
+ return this.healthMessage;
327
+ }
328
+ else if (this.healthCallback) {
329
+ const resp = await this.healthCallback();
330
+ const [status, msg] = typeof resp === 'string' ? [500, resp] : [resp?.status, resp?.message];
331
+ if (!!msg || !!status) {
332
+ res.log.error(resp, 'Health check callback failed.');
333
+ void res.status(status ?? 500);
334
+ return msg ?? 'FAIL';
335
+ }
336
+ }
337
+ return 'OK';
338
+ });
339
+ this.sigHandler = () => {
340
+ this.close().then(() => {
341
+ process.exit();
342
+ }).catch((e) => { this.app.log.error(e, 'Error during shutdown'); });
343
+ };
344
+ process.on('SIGTERM', this.sigHandler);
345
+ process.on('SIGINT', this.sigHandler);
346
+ }
347
+ async start(port) {
348
+ const customPort = port ?? parseInt(process.env.PORT ?? '0', 10);
349
+ await this.app.ready();
350
+ if (this.swaggerEndpoint)
351
+ this.app.swagger();
352
+ if (customPort) {
353
+ await this.app.listen({ port: customPort, host: '0.0.0.0' });
354
+ }
355
+ else if (this.https) {
356
+ // redirect 80 to 443
357
+ http.createServer((req, res) => {
358
+ res.writeHead(301, { Location: 'https://' + (req.headers.host?.replace(/:\d+$/v, '') ?? '') + (req.url ?? '') });
359
+ res.end();
360
+ }).listen(80);
361
+ await this.app.listen({ port: 443, host: '0.0.0.0' });
362
+ }
363
+ else {
364
+ await this.app.listen({ port: 80, host: '0.0.0.0' });
365
+ }
366
+ }
367
+ addErrorHandler(handler) {
368
+ this.errorHandlers.push(handler);
369
+ }
370
+ setUnhealthy(message) {
371
+ this.healthMessage = message;
372
+ }
373
+ setHealthy() {
374
+ this.healthMessage = undefined;
375
+ }
376
+ setValidOrigins(origins) {
377
+ this.originChecker.setValidOrigins(origins);
378
+ }
379
+ setValidOriginHosts(hosts) {
380
+ this.originChecker.setValidOriginHosts(hosts);
381
+ }
382
+ setValidOriginSuffixes(suffixes) {
383
+ this.originChecker.setValidOriginSuffixes(suffixes);
384
+ }
385
+ async swagger(opts) {
386
+ let openapi = opts?.openapi ?? {};
387
+ if (this.config.authenticate != null) {
388
+ openapi = set(openapi, 'components.securitySchemes', {
389
+ unifiedAuth: {
390
+ type: 'http',
391
+ scheme: 'bearer',
392
+ bearerFormat: 'JWT',
393
+ description: `Enter a token obtained from the TxState Unified Authentication service. An easy way to do
394
+ this is log into this application and use dev tools to pull your token from the Authorization header.`
395
+ }
396
+ });
397
+ // Apply the security globally to all operations
398
+ openapi.security = [{ unifiedAuth: [] }];
399
+ }
400
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return -- findRefs traverses arbitrary JSON schema objects */
401
+ function findRefs(obj, id) {
402
+ if (obj == null)
403
+ return undefined;
404
+ if (obj.$id?.length)
405
+ id = obj.$id;
406
+ if (obj.$ref === '#' && id?.length) {
407
+ obj.type = 'string';
408
+ obj.enum = [id];
409
+ delete obj.$ref;
410
+ }
411
+ else {
412
+ for (const val of Object.values(obj)) {
413
+ if (typeof val === 'object' && !(val instanceof Date))
414
+ findRefs(val, id);
415
+ }
416
+ }
417
+ return obj;
418
+ }
419
+ await this.app.register(swagger, {
420
+ openapi,
421
+ transform(transformArgs) {
422
+ const newSchema = findRefs(clone(transformArgs.schema));
423
+ return { ...transformArgs, schema: newSchema };
424
+ }
425
+ });
426
+ /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
427
+ this.swaggerEndpoint = opts?.path ?? opts?.ui?.routePrefix ?? '/docs';
428
+ await this.app.register(swaggerUI, { ...opts?.ui, routePrefix: this.swaggerEndpoint });
429
+ }
430
+ async close(softSeconds) {
431
+ if (typeof softSeconds === 'undefined')
432
+ softSeconds = parseInt(process.env.LOAD_BALANCE_TIMEOUT ?? '0', 10);
433
+ process.removeListener('SIGTERM', this.sigHandler);
434
+ process.removeListener('SIGINT', this.sigHandler);
435
+ if (softSeconds) {
436
+ this.shuttingDown = true;
437
+ await sleep(softSeconds);
438
+ }
439
+ await this.app.close();
440
+ }
441
+ }
@@ -1,9 +1,11 @@
1
- import { type FastifyReply, type FastifyRequest } from 'fastify';
2
- import { type IssuerConfig, type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from '.';
3
- export interface IssuerConfigRaw extends Omit<IssuerConfig, 'validateUrl' | 'logoutUrl'> {
4
- validateUrl?: string;
5
- logoutUrl?: string;
6
- }
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ import { type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from './server.ts';
3
+ /**
4
+ * @deprecated Use `jwtAuthenticate(options)` instead. Note the new shape: `jwtAuthenticate`
5
+ * is now a factory that takes options up front and returns the authenticator function
6
+ * (`authenticate: jwtAuthenticate({ authenticateAll: true })`), so the options actually
7
+ * take effect when wired into `new Server({ authenticate })`.
8
+ */
7
9
  export declare function unifiedAuthenticate(req: FastifyRequest, options?: {
8
10
  authenticateAll?: boolean;
9
11
  exceptRoutes?: Set<string>;
@@ -11,7 +13,7 @@ export declare function unifiedAuthenticate(req: FastifyRequest, options?: {
11
13
  usingUaCookieRoutes?: boolean;
12
14
  }): Promise<FastifyTxStateAuthInfo | undefined>;
13
15
  /**
14
- * @deprecated Use unifiedAuthenticateWithOptions with { authenticateAll: true } instead.
16
+ * @deprecated Use `jwtAuthenticate({ authenticateAll: true })` instead.
15
17
  */
16
18
  export declare function unifiedAuthenticateAll(req: FastifyRequest): Promise<FastifyTxStateAuthInfo>;
17
19
  /**
@@ -19,5 +21,9 @@ export declare function unifiedAuthenticateAll(req: FastifyRequest): Promise<Fas
19
21
  * using a framework. It will automatically redirect the user to the Unified Auth login page
20
22
  * and return true if they are not authenticated. Otherwise it simply returns false.
21
23
  */
24
+ export declare function requireCookieAuthUa(req: FastifyRequest, res: FastifyReply): Promise<boolean>;
25
+ /**
26
+ * @deprecated Use requireCookieAuthUa instead.
27
+ */
22
28
  export declare function requireCookieAuth(req: FastifyRequest, res: FastifyReply): Promise<boolean>;
23
29
  export declare function registerUaCookieRoutes(app: FastifyInstanceTyped): void;