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/README.md +392 -7
- package/lib/analytics.d.ts +5 -2
- package/lib/analytics.js +28 -33
- package/lib/error.d.ts +1 -10
- package/lib/error.js +7 -28
- package/lib/filestorage.d.ts +1 -1
- package/lib/filestorage.js +27 -31
- package/lib/index.d.ts +9 -194
- package/lib/index.js +9 -422
- package/lib/jwt-auth.d.ts +98 -0
- package/lib/jwt-auth.js +491 -0
- package/lib/oauth.d.ts +49 -0
- package/lib/oauth.js +272 -0
- package/lib/postformdata.js +13 -17
- package/{lib-esm/index.d.ts → lib/server.d.ts} +69 -37
- package/lib/server.js +441 -0
- package/lib/unified-auth.d.ts +13 -7
- package/lib/unified-auth.js +48 -174
- package/package.json +27 -25
- package/lib-esm/index.js +0 -20
- package/lib-esm/package.json +0 -3
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
|
+
}
|
package/lib/unified-auth.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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;
|