fastify-txstate 3.6.9 → 4.0.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/lib/index.js CHANGED
@@ -1,422 +1,8 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
- };
16
- var __importDefault = (this && this.__importDefault) || function (mod) {
17
- return (mod && mod.__esModule) ? mod : { "default": mod };
18
- };
19
- Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.prodLogger = exports.devLogger = void 0;
21
- const ajv_1 = __importDefault(require("ajv"));
22
- const swagger_1 = __importDefault(require("@fastify/swagger"));
23
- const swagger_ui_1 = __importDefault(require("@fastify/swagger-ui"));
24
- const fastify_shared_1 = require("@txstate-mws/fastify-shared");
25
- const ajv_errors_1 = __importDefault(require("ajv-errors"));
26
- const ajv_formats_1 = __importDefault(require("ajv-formats"));
27
- const fastify_1 = require("fastify");
28
- const node_fs_1 = __importDefault(require("node:fs"));
29
- const node_http_1 = __importDefault(require("node:http"));
30
- const txstate_utils_1 = require("txstate-utils");
31
- const error_1 = require("./error");
32
- exports.devLogger = {
33
- level: 'info',
34
- info: (msg) => { console.info(msg.req ? `${msg.req.method} ${msg.req.url}` : msg.res ? `${msg.res.statusCode} - ${msg.responseTime}` : msg); },
35
- error: console.error,
36
- debug: console.debug,
37
- fatal: console.error,
38
- warn: console.warn,
39
- trace: console.trace,
40
- silent: (msg) => { },
41
- child(bindings, options) { return this; }
42
- };
43
- exports.prodLogger = {
44
- level: 'info',
45
- serializers: {
46
- req(req) {
47
- return {
48
- method: req.method,
49
- url: req.url.replace(/(token|unifiedJwt)=[\w.]+/i, '$1=redacted'),
50
- remoteAddress: req.ip,
51
- traceparent: req.headers.traceparent
52
- };
53
- },
54
- res(res) {
55
- return {
56
- statusCode: res.statusCode,
57
- url: res.request?.url.replace(/(token|unifiedJwt)=[\w.]+/i, '$1=redacted'),
58
- length: Number((0, txstate_utils_1.toArray)(res.getHeader?.('content-length'))[0]),
59
- ...res.extraLogInfo,
60
- auth: (0, txstate_utils_1.omit)(res.request?.auth ?? res.extraLogInfo?.auth ?? {}, 'token', 'issuerConfig')
61
- };
62
- }
63
- }
64
- };
65
- class Server {
66
- config;
67
- https = false;
68
- errorHandlers = [];
69
- healthMessage;
70
- healthCallback;
71
- shuttingDown = false;
72
- sigHandler;
73
- validOrigins = {};
74
- validOriginHosts = {};
75
- validOriginSuffixes = new Set();
76
- swaggerEndpoint;
77
- app;
78
- constructor(config = {}) {
79
- this.config = config;
80
- try {
81
- const key = node_fs_1.default.readFileSync('/securekeys/private.key');
82
- const cert = node_fs_1.default.readFileSync('/securekeys/cert.pem');
83
- config.https = {
84
- ...config.https,
85
- allowHTTP1: true,
86
- key,
87
- cert,
88
- minVersion: 'TLSv1.2'
89
- };
90
- config.http2 = true;
91
- this.https = true;
92
- }
93
- catch (e) {
94
- this.https = false;
95
- delete config.https;
96
- }
97
- if (typeof config.logger === 'undefined') {
98
- config.logger = process.env.NODE_ENV === 'development'
99
- ? exports.devLogger
100
- : exports.prodLogger;
101
- }
102
- if (process.env.TRUST_PROXY != null) {
103
- if (['true', '1'].includes(process.env.TRUST_PROXY))
104
- config.trustProxy = true;
105
- else
106
- config.trustProxy = process.env.TRUST_PROXY;
107
- }
108
- config.ajv = { ...config.ajv, plugins: [...(config.ajv?.plugins ?? []), ajv_errors_1.default, [ajv_formats_1.default, { mode: 'fast' }]], customOptions: { ...config.ajv?.customOptions, allErrors: true, strictSchema: false, coerceTypes: true } };
109
- this.healthCallback = config.checkHealth;
110
- this.app = (0, fastify_1.fastify)(config);
111
- this.app.addHook('onRoute', route => {
112
- if (!route.schema?.body)
113
- return;
114
- const missingResponse = route.schema?.response == null;
115
- const response400 = (0, txstate_utils_1.set)(fastify_shared_1.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.');
116
- let newSchema = (0, txstate_utils_1.set)(route.schema ?? {}, 'response.400', response400);
117
- const response422 = (0, txstate_utils_1.set)(fastify_shared_1.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.');
118
- newSchema = (0, txstate_utils_1.set)(newSchema, 'response.422', response422);
119
- if (missingResponse) {
120
- newSchema.response['200'] = {
121
- description: 'Success. Return type has not been specified.',
122
- type: 'object'
123
- };
124
- }
125
- route.schema = newSchema;
126
- });
127
- this.app.addHook('preValidation', (req, res, done) => {
128
- if (req.body != null && req.routeOptions.schema?.body)
129
- (0, txstate_utils_1.destroyNulls)(req.body);
130
- done();
131
- });
132
- // use Ajv to validate responses instead of @fastify/json-fast-stringify since ajv does
133
- // a better job with recursive types and we don't want to have different behavior between
134
- // input and output validation
135
- const ajv = new ajv_1.default(config.ajv.customOptions);
136
- for (const pluginConfig of config.ajv.plugins ?? []) {
137
- const [plugin, opts] = (0, txstate_utils_1.toArray)(pluginConfig);
138
- plugin(ajv, opts);
139
- }
140
- this.app.setSerializerCompiler((route) => {
141
- const schema = route.schema;
142
- const validate = schema == null ? ajv.compile({ type: 'object' }) : ajv.compile(schema);
143
- return data => {
144
- /**
145
- * Ajv unfortunately treats optional properties as non-nullable, so they're allowed to
146
- * be undefined but not allowed to be null. Worse, with `coerceTypes`, null will be converted
147
- * to empty string or 0 or false. This is silly behavior, so we're converting all nulls to
148
- * undefined before we validate.
149
- */
150
- if (schema != null)
151
- (0, txstate_utils_1.destroyNulls)((0, txstate_utils_1.stringifyDates)(data));
152
- if (!validate(data))
153
- throw new Error('Output validation failed. ' + validate.errors?.[0].instancePath + ': ' + validate.errors?.[0].message);
154
- return JSON.stringify(data);
155
- };
156
- });
157
- this.app.addHook('onRequest', (req, res, done) => {
158
- res.extraLogInfo = {};
159
- done();
160
- });
161
- if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
162
- this.setValidOrigins([...(config.validOrigins ?? []), ...(process.env.VALID_ORIGINS?.split(',') ?? [])]);
163
- this.setValidOriginHosts([...(config.validOriginHosts ?? []), ...(process.env.VALID_ORIGIN_HOSTS?.split(',') ?? [])]);
164
- this.setValidOriginSuffixes([...(config.validOriginSuffixes ?? []), ...(process.env.VALID_ORIGIN_SUFFIXES?.split(',') ?? [])]);
165
- this.app.addHook('onRequest', async (req, res) => {
166
- if (!req.headers.origin)
167
- return;
168
- let passed = this.validOrigins[req.headers.origin];
169
- if (!passed && req.headers.origin === 'null')
170
- passed = process.env.NODE_ENV === 'development';
171
- else if (!passed) {
172
- const parsedOrigin = new URL(req.headers.origin);
173
- if (req.hostname.replace(/:\d+$/, '') === parsedOrigin.hostname)
174
- passed = true;
175
- if (this.validOriginHosts[parsedOrigin.hostname])
176
- passed = true;
177
- if (!passed && this.validOriginSuffixes.size > 0) {
178
- const originParts = parsedOrigin.hostname.split('.');
179
- for (let i = 0; i < originParts.length; i++) {
180
- const suffix = originParts.slice(i).join('.');
181
- if (this.validOriginSuffixes.has(suffix))
182
- passed = true;
183
- }
184
- }
185
- }
186
- if (!passed && config.checkOrigin?.(req))
187
- passed = true;
188
- if (!passed) {
189
- await res.status(403).send('Origin check failed. Suspected XSRF attack.');
190
- return res;
191
- }
192
- else {
193
- void res.header('Access-Control-Allow-Origin', req.headers.origin);
194
- void res.header('Access-Control-Max-Age', '600'); // ask browser to skip pre-flights for 10 minutes after a yes
195
- if (req.headers['access-control-request-method'])
196
- void res.header('access-control-allow-methods', req.headers['access-control-request-method']);
197
- if (req.headers['access-control-request-headers'])
198
- void res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
199
- }
200
- });
201
- this.app.options('*', async (req, res) => {
202
- await res.send();
203
- });
204
- }
205
- if (config.authenticate) {
206
- const authenticatedMethods = {
207
- GET: true,
208
- POST: true,
209
- PUT: true,
210
- PATCH: true,
211
- DELETE: true
212
- };
213
- this.app.addHook('onRequest', async (req, res) => {
214
- if (!authenticatedMethods[req.method] || (0, txstate_utils_1.isBlank)(req.routeOptions.url) || req.routeOptions.url === '/health' || req.routeOptions.url === '/.uaService' || (this.swaggerEndpoint && req.routeOptions.url?.startsWith(this.swaggerEndpoint)))
215
- return;
216
- try {
217
- req.auth = await config.authenticate(req);
218
- }
219
- catch (e) {
220
- await res.status(401).send('Failed to authenticate.');
221
- return res;
222
- }
223
- });
224
- }
225
- this.app.addHook('onSend', this.https && process.env.NODE_ENV !== 'development'
226
- ? async (_, resp) => {
227
- void resp.removeHeader('X-Powered-By');
228
- void resp.header('Strict-Transport-Security', 'max-age=31536000');
229
- if (resp.getHeader('content-type') === 'text/html')
230
- void resp.type('text/html; charset=utf-8');
231
- }
232
- : async (_, resp) => {
233
- void resp.removeHeader('X-Powered-By');
234
- if (resp.getHeader('content-type') === 'text/html')
235
- void resp.type('text/html; charset=utf-8');
236
- });
237
- this.app.setNotFoundHandler((req, res) => { void res.status(404).send('Not Found.'); });
238
- this.app.setErrorHandler(async (err, req, res) => {
239
- req.log.warn(err);
240
- for (const errorHandler of this.errorHandlers) {
241
- if (!res.sent)
242
- await errorHandler(err, req, res);
243
- }
244
- if (!res.sent) {
245
- if (err instanceof error_1.FailedValidationError) {
246
- await res.status(err.statusCode).send(err.errors);
247
- }
248
- else if (err instanceof error_1.ValidationError) {
249
- await res.status(err.statusCode).send({ success: false, messages: [{ message: err.message, path: err.path, type: err.type ?? 'error' }] });
250
- }
251
- else if (err instanceof error_1.ValidationErrors) {
252
- await res.status(err.statusCode).send({ success: false, messages: err.errors });
253
- }
254
- else if (err instanceof error_1.HttpError) {
255
- await res.status(err.statusCode).send(err.message);
256
- }
257
- else if (err.code === 'FST_ERR_VALIDATION') {
258
- const developerErrors = [];
259
- const userErrors = [];
260
- for (const v of err.validation ?? []) {
261
- if (v.keyword === 'errorMessage') {
262
- for (const ov of v.params.errors) {
263
- if (['type', 'additionalProperties', 'minProperties'].includes(ov.keyword))
264
- developerErrors.push({ ...ov, message: v.message });
265
- else if (ov.keyword === 'required')
266
- userErrors.push({ ...ov, message: 'This field is required.' });
267
- else
268
- userErrors.push({ ...ov, message: v.message });
269
- }
270
- }
271
- else {
272
- if (['type', 'additionalProperties', 'minProperties'].includes(v.keyword))
273
- developerErrors.push(v);
274
- else if (v.keyword === 'required')
275
- userErrors.push({ ...v, message: 'This field is required.' });
276
- else
277
- userErrors.push(v);
278
- }
279
- }
280
- if (userErrors.length)
281
- await res.status(422).send({ success: false, messages: userErrors.map(error_1.fstValidationToMessage) });
282
- else
283
- await res.status(400).send(developerErrors.map(error_1.fstValidationToMessage));
284
- }
285
- else if (err.statusCode) {
286
- await res.status(err.statusCode).send(new error_1.HttpError(err.statusCode).message);
287
- }
288
- else {
289
- await res.status(500).send('Internal Server Error.');
290
- }
291
- }
292
- });
293
- this.app.get('/health', { logLevel: 'warn' }, async (req, res) => {
294
- if (this.shuttingDown) {
295
- res.log.warn('Returning 503 on /health because we are shutting down/restarting.');
296
- void res.status(503);
297
- return 'MAINTENANCE';
298
- }
299
- else if (this.healthMessage) {
300
- res.log.error(this.healthMessage);
301
- void res.status(500);
302
- return this.healthMessage;
303
- }
304
- else if (this.healthCallback) {
305
- const resp = await this.healthCallback();
306
- const [status, msg] = typeof resp === 'string' ? [500, resp] : [resp?.status, resp?.message];
307
- if (!!msg || !!status) {
308
- res.log.error(resp, 'Health check callback failed.');
309
- void res.status(status ?? 500);
310
- return msg ?? 'FAIL';
311
- }
312
- }
313
- return 'OK';
314
- });
315
- this.sigHandler = () => {
316
- this.close().then(() => {
317
- process.exit();
318
- }).catch(e => { console.error(e); });
319
- };
320
- process.on('SIGTERM', this.sigHandler);
321
- process.on('SIGINT', this.sigHandler);
322
- }
323
- async start(port) {
324
- const customPort = port ?? parseInt(process.env.PORT ?? '0');
325
- await this.app.ready();
326
- this.app.swagger?.();
327
- if (customPort) {
328
- await this.app.listen({ port: customPort, host: '0.0.0.0' });
329
- }
330
- else if (this.https) {
331
- // redirect 80 to 443
332
- node_http_1.default.createServer((req, res) => {
333
- res.writeHead(301, { Location: 'https://' + (req?.headers?.host?.replace(/:\d+$/, '') ?? '') + (req.url ?? '') });
334
- res.end();
335
- }).listen(80);
336
- await this.app.listen({ port: 443, host: '0.0.0.0' });
337
- }
338
- else {
339
- await this.app.listen({ port: 80, host: '0.0.0.0' });
340
- }
341
- }
342
- addErrorHandler(handler) {
343
- this.errorHandlers.push(handler);
344
- }
345
- setUnhealthy(message) {
346
- this.healthMessage = message;
347
- }
348
- setHealthy() {
349
- this.healthMessage = undefined;
350
- }
351
- setValidOrigins(origins) {
352
- this.validOrigins = origins.reduce((validOrigins, origin) => ({ ...validOrigins, [origin]: true }), {});
353
- }
354
- setValidOriginHosts(hosts) {
355
- this.validOriginHosts = hosts.reduce((validHosts, host) => ({ ...validHosts, [host]: true }), {});
356
- }
357
- setValidOriginSuffixes(suffixes) {
358
- this.validOriginSuffixes.clear();
359
- for (const s of suffixes)
360
- this.validOriginSuffixes.add(s.replace(/^\./, ''));
361
- }
362
- async swagger(opts) {
363
- let openapi = opts?.openapi ?? {};
364
- if (this.config.authenticate != null) {
365
- openapi = (0, txstate_utils_1.set)(openapi, 'components.securitySchemes', {
366
- unifiedAuth: {
367
- type: 'http',
368
- scheme: 'bearer',
369
- bearerFormat: 'JWT',
370
- description: `Enter a token obtained from the TxState Unified Authentication service. An easy way to do
371
- this is log into this application and use dev tools to pull your token from the Authorization header.`
372
- }
373
- });
374
- // Apply the security globally to all operations
375
- openapi.security = [{ unifiedAuth: [] }];
376
- }
377
- function findRefs(obj, id) {
378
- if (obj == null)
379
- return undefined;
380
- if (obj.$id?.length)
381
- id = obj.$id;
382
- if (obj.$ref === '#' && id?.length) {
383
- obj.type = 'string';
384
- obj.enum = [id];
385
- delete obj.$ref;
386
- }
387
- else {
388
- for (const val of Object.values(obj)) {
389
- if (typeof val === 'object' && !(val instanceof Date))
390
- findRefs(val, id);
391
- }
392
- }
393
- return obj;
394
- }
395
- await this.app.register(swagger_1.default, {
396
- openapi,
397
- transform(transformArgs) {
398
- const newSchema = findRefs((0, txstate_utils_1.clone)(transformArgs.schema));
399
- return { ...transformArgs, schema: newSchema };
400
- }
401
- });
402
- this.swaggerEndpoint = opts?.path ?? opts?.ui?.routePrefix ?? '/docs';
403
- await this.app.register(swagger_ui_1.default, { ...opts?.ui, routePrefix: this.swaggerEndpoint });
404
- }
405
- async close(softSeconds) {
406
- if (typeof softSeconds === 'undefined')
407
- softSeconds = parseInt(process.env.LOAD_BALANCE_TIMEOUT ?? '0');
408
- process.removeListener('SIGTERM', this.sigHandler);
409
- process.removeListener('SIGINT', this.sigHandler);
410
- if (softSeconds) {
411
- this.shuttingDown = true;
412
- await (0, txstate_utils_1.sleep)(softSeconds);
413
- }
414
- await this.app.close();
415
- }
416
- }
417
- exports.default = Server;
418
- __exportStar(require("./analytics"), exports);
419
- __exportStar(require("./error"), exports);
420
- __exportStar(require("./filestorage"), exports);
421
- __exportStar(require("./unified-auth"), exports);
422
- __exportStar(require("./postformdata"), exports);
1
+ export { default } from "./server.js";
2
+ export * from "./server.js";
3
+ export * from "./analytics.js";
4
+ export * from "./error.js";
5
+ export * from "./filestorage.js";
6
+ export * from "./unified-auth.js";
7
+ export * from "./oauth.js";
8
+ export * from "./postformdata.js";
package/lib/oauth.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import { type FastifyTxStateAuthInfo, type FastifyInstanceTyped } from './server.ts';
3
+ declare module 'fastify' {
4
+ interface FastifyRequest {
5
+ pendingOAuthCookies?: string[];
6
+ }
7
+ }
8
+ /**
9
+ * Authenticate requests using JWT tokens from any OAuth/OIDC provider. The token's
10
+ * issuer claim is used to auto-discover the provider's JWKS endpoint for signature
11
+ * verification.
12
+ *
13
+ * Expects JWT tokens (access tokens or ID tokens) in the Authorization Bearer header
14
+ * or in a cookie set by registerOAuthCookieRoutes.
15
+ *
16
+ * For providers like Google that issue opaque access tokens, have the client send the
17
+ * ID token instead — it's a standard JWT that proves the user's identity without
18
+ * requiring a round-trip to the provider on every request.
19
+ */
20
+ export declare function oauthAuthenticate(req: FastifyRequest, options?: {
21
+ /** If true, all requests require authentication, except routes listed in exceptRoutes or optionalRoutes. */
22
+ authenticateAll?: boolean;
23
+ /** Routes that skip authentication entirely. They will not receive an auth object. */
24
+ exceptRoutes?: Set<string>;
25
+ /** Routes that do not require authentication, but will fill req.auth if a session is available. */
26
+ optionalRoutes?: Set<string>;
27
+ /** Set this true if you are using registerOAuthCookieRoutes and authenticateAll. */
28
+ usingOAuthCookieRoutes?: boolean;
29
+ /** Receives the full JWT payload and returns extra properties to merge into the auth object.
30
+ * If you use this, you should also set OAUTH_TRUSTED_AUDIENCES to prevent tokens from
31
+ * other applications carrying unexpected authorization claims. */
32
+ extraClaims?: (payload: Record<string, unknown>) => Record<string, unknown>;
33
+ }): Promise<FastifyTxStateAuthInfo | undefined>;
34
+ /**
35
+ * Register cookie-based OAuth login/logout endpoints. Uses the authorization code flow
36
+ * with PKCE (S256) to exchange a code for tokens, then stores the ID token in an HttpOnly
37
+ * cookie. The access token and refresh token are stored in separate cookies (optionally
38
+ * encrypted via OAUTH_COOKIE_SECRET) so that the ID token can be transparently refreshed
39
+ * when it expires and the access token is available at `req.auth.accessToken` for calling
40
+ * provider APIs.
41
+ *
42
+ * Requires OAUTH_CLIENT_ID environment variable. OAUTH_CLIENT_SECRET is optional — PKCE
43
+ * provides the security for the code exchange, but some providers require a client secret
44
+ * even with PKCE. OAUTH_COOKIE_SECRET is optional — if set, the access token and refresh
45
+ * token cookies are encrypted with AES-256-GCM; if not, they are stored as plaintext
46
+ * (still HttpOnly and Secure).
47
+ *
48
+ * Registers three routes:
49
+ * - `/.oauthRedirect` - Redirects to the OAuth provider's login page. The client passes
50
+ * `requestedUrl` (required) which is sent to the provider as the `state` parameter,
51
+ * round-tripped back, and used as the redirect destination after login.
52
+ * - `/.oauthCallback` - Handles the provider's redirect, exchanges the code for tokens
53
+ * using the PKCE code verifier. Sets the ID token (or JWT access token as fallback),
54
+ * access token, and refresh token as cookies.
55
+ * - `/.oauthLogout` - Clears all OAuth cookies and redirects to the provider's logout
56
+ * endpoint if available.
57
+ */
58
+ export interface IssuerChoice {
59
+ issuerUrl: string;
60
+ redirectHref: string;
61
+ }
62
+ export declare function registerOAuthCookieRoutes(app: FastifyInstanceTyped, options?: {
63
+ /** Scopes to always include in the authorization request, merged with any scopes
64
+ * the client passes via the `scope` query parameter. */
65
+ scopes?: string[];
66
+ /** When multiple issuers are configured and the client doesn't specify one,
67
+ * this function is called to render a login selection page. It receives an array
68
+ * of issuer URLs with their corresponding redirect hrefs and should return an HTML
69
+ * string. If not provided, the first trusted issuer is used. */
70
+ loginPage?: (issuers: IssuerChoice[]) => string;
71
+ }): void;