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/README.md +354 -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 +8 -194
- package/lib/index.js +8 -422
- package/lib/oauth.d.ts +71 -0
- package/lib/oauth.js +507 -0
- package/lib/postformdata.js +13 -17
- package/{lib-esm/index.d.ts → lib/server.d.ts} +69 -37
- package/lib/server.js +440 -0
- package/lib/unified-auth.d.ts +2 -2
- package/lib/unified-auth.js +55 -47
- package/package.json +27 -25
- package/lib-esm/index.js +0 -20
- package/lib-esm/package.json +0 -3
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
/// <reference types="node" />
|
|
2
|
-
/// <reference types="node" />
|
|
3
1
|
import { type FastifyDynamicSwaggerOptions } from '@fastify/swagger';
|
|
4
2
|
import { type FastifySwaggerUiOptions } from '@fastify/swagger-ui';
|
|
5
3
|
import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
|
|
6
|
-
import { type FastifyInstance, type FastifyRequest, type FastifyReply, type FastifyServerOptions, type
|
|
4
|
+
import { type FastifyInstance, type FastifyRequest, type FastifyReply, type FastifyServerOptions, type FastifyBaseLogger, type RawServerDefault } from 'fastify';
|
|
5
|
+
import pino from 'pino';
|
|
7
6
|
import http from 'node:http';
|
|
8
7
|
import type http2 from 'node:http2';
|
|
9
8
|
type ErrorHandler = (error: Error, req: FastifyRequest, res: FastifyReply) => Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Get the base URL for this API. Uses PUBLIC_URL if set, otherwise derives
|
|
11
|
+
* from the request's protocol and hostname.
|
|
12
|
+
*/
|
|
13
|
+
export declare function apiBaseUrl(req: FastifyRequest): string;
|
|
14
|
+
/**
|
|
15
|
+
* Get the base URL for the UI that this API serves. Uses UI_URL if set,
|
|
16
|
+
* otherwise assumes the API lives at a subpath (e.g. /api) and the UI is
|
|
17
|
+
* one level up. Falls back to the API base URL if there's no parent path.
|
|
18
|
+
*/
|
|
19
|
+
export declare function uiBaseUrl(req: FastifyRequest): string;
|
|
10
20
|
export interface FastifyTxStateAuthInfo {
|
|
11
21
|
/**
|
|
12
22
|
* The primary identifier for the user that is making the request, after processing
|
|
@@ -27,6 +37,13 @@ export interface FastifyTxStateAuthInfo {
|
|
|
27
37
|
* If all else fails, you can sha256 the session token with a salt.
|
|
28
38
|
*/
|
|
29
39
|
sessionId: string;
|
|
40
|
+
/**
|
|
41
|
+
* The date that the session was created, if available. This is useful for considering
|
|
42
|
+
* tokens before a certain date as invalid. For instance, if you want a logout action
|
|
43
|
+
* to invalidate all tokens created until that point, you can compare this field against
|
|
44
|
+
* the last time they logged out.
|
|
45
|
+
*/
|
|
46
|
+
sessionCreatedAt?: Date;
|
|
30
47
|
/**
|
|
31
48
|
* Some authentication systems allow administrators to impersonate regular users, so that
|
|
32
49
|
* they can see what that user sees and troubleshoot issues. We still want to log the administrator
|
|
@@ -45,6 +62,39 @@ export interface FastifyTxStateAuthInfo {
|
|
|
45
62
|
* this field can help log requests that are authenticated with the other application's token.
|
|
46
63
|
*/
|
|
47
64
|
clientId?: string;
|
|
65
|
+
/**
|
|
66
|
+
* A string that designates the current session as one that has limited authorization. The application will
|
|
67
|
+
* be responsible for checking this field and restricting appropriately.
|
|
68
|
+
*
|
|
69
|
+
* For example, a user who authenticated via non-standard mechanism might be given a scope of 'altlogin' and
|
|
70
|
+
* only a portion of the application's functionality would be available to them.
|
|
71
|
+
*/
|
|
72
|
+
scope?: string;
|
|
73
|
+
/**
|
|
74
|
+
* The token or key that was used to authenticate the request. This is useful for
|
|
75
|
+
* making sub-requests to other APIs that can authenticate with the same token.
|
|
76
|
+
*/
|
|
77
|
+
token: string;
|
|
78
|
+
/**
|
|
79
|
+
* The issuer configuration for the token, if applicable. This helps you generate
|
|
80
|
+
* a proper logout url in multi-issuer environments.
|
|
81
|
+
*/
|
|
82
|
+
issuerConfig?: IssuerConfig;
|
|
83
|
+
/**
|
|
84
|
+
* The OAuth access token, if available. This is useful when your API needs to make
|
|
85
|
+
* requests to the provider's APIs on behalf of the user (e.g. Google Drive, Microsoft
|
|
86
|
+
* Graph). Only populated when using cookie-based OAuth with a provider that returns
|
|
87
|
+
* an access token during the code exchange.
|
|
88
|
+
*/
|
|
89
|
+
accessToken?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface IssuerConfig {
|
|
92
|
+
iss: string;
|
|
93
|
+
url?: string;
|
|
94
|
+
publicKey?: string;
|
|
95
|
+
secret?: string;
|
|
96
|
+
validateUrl?: URL;
|
|
97
|
+
logoutUrl?: URL;
|
|
48
98
|
}
|
|
49
99
|
export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
|
|
50
100
|
https?: http2.SecureServerOptions;
|
|
@@ -82,39 +132,24 @@ export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
|
|
|
82
132
|
declare module 'fastify' {
|
|
83
133
|
interface FastifyRequest {
|
|
84
134
|
auth?: FastifyTxStateAuthInfo;
|
|
135
|
+
originChecker?: OriginChecker;
|
|
85
136
|
}
|
|
86
137
|
interface FastifyReply {
|
|
87
138
|
extraLogInfo: any;
|
|
88
139
|
}
|
|
89
140
|
}
|
|
90
|
-
export declare const devLogger:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
(...data: any[]): void;
|
|
103
|
-
(message?: any, ...optionalParams: any[]): void;
|
|
104
|
-
};
|
|
105
|
-
warn: {
|
|
106
|
-
(...data: any[]): void;
|
|
107
|
-
(message?: any, ...optionalParams: any[]): void;
|
|
108
|
-
};
|
|
109
|
-
trace: {
|
|
110
|
-
(...data: any[]): void;
|
|
111
|
-
(message?: any, ...optionalParams: any[]): void;
|
|
112
|
-
};
|
|
113
|
-
silent: (msg: any) => void;
|
|
114
|
-
child(bindings: any, options?: any): any;
|
|
115
|
-
};
|
|
116
|
-
export declare const prodLogger: FastifyLoggerOptions;
|
|
117
|
-
export type FastifyInstanceTyped = FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, FastifyBaseLogger, JsonSchemaToTsProvider>;
|
|
141
|
+
export declare const devLogger: pino.Logger<never, boolean>;
|
|
142
|
+
export declare const prodLogger: pino.Logger<never, boolean>;
|
|
143
|
+
export type FastifyInstanceTyped = FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse, FastifyBaseLogger, JsonSchemaToTsProvider>;
|
|
144
|
+
export declare class OriginChecker {
|
|
145
|
+
protected validOrigins: Record<string, boolean>;
|
|
146
|
+
protected validOriginHosts: Record<string, boolean>;
|
|
147
|
+
protected validOriginSuffixes: Set<string>;
|
|
148
|
+
setValidOrigins(origins: string[]): void;
|
|
149
|
+
setValidOriginHosts(hosts: string[]): void;
|
|
150
|
+
setValidOriginSuffixes(suffixes: string[]): void;
|
|
151
|
+
check(hostname: string, requestHostname?: string): boolean;
|
|
152
|
+
}
|
|
118
153
|
export type TxServer = Server;
|
|
119
154
|
export default class Server {
|
|
120
155
|
protected config: FastifyTxStateOptions & {
|
|
@@ -129,9 +164,8 @@ export default class Server {
|
|
|
129
164
|
} | undefined>;
|
|
130
165
|
protected shuttingDown: boolean;
|
|
131
166
|
protected sigHandler: (signal: any) => void;
|
|
132
|
-
protected
|
|
133
|
-
protected
|
|
134
|
-
protected validOriginSuffixes: Set<string>;
|
|
167
|
+
protected originChecker: OriginChecker;
|
|
168
|
+
protected swaggerEndpoint: string | undefined;
|
|
135
169
|
app: FastifyInstanceTyped;
|
|
136
170
|
constructor(config?: FastifyTxStateOptions & {
|
|
137
171
|
http2?: true;
|
|
@@ -150,6 +184,4 @@ export default class Server {
|
|
|
150
184
|
}): Promise<void>;
|
|
151
185
|
close(softSeconds?: number): Promise<void>;
|
|
152
186
|
}
|
|
153
|
-
export
|
|
154
|
-
export * from './error';
|
|
155
|
-
export * from './unified-auth';
|
|
187
|
+
export {};
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
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
|
+
config.ajv = { ...config.ajv, mode: undefined, plugins: [...(config.ajv?.plugins ?? []), ajvErrors, [ajvFormats, { mode: 'fast' }]], customOptions: { ...config.ajv?.customOptions, allErrors: true, strictSchema: false, coerceTypes: true } };
|
|
153
|
+
this.healthCallback = config.checkHealth;
|
|
154
|
+
this.app = fastify(config);
|
|
155
|
+
this.app.addHook('onRoute', route => {
|
|
156
|
+
if (!route.schema?.body)
|
|
157
|
+
return;
|
|
158
|
+
const missingResponse = route.schema.response == null;
|
|
159
|
+
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.');
|
|
160
|
+
let newSchema = set(route.schema ?? {}, 'response.400', response400);
|
|
161
|
+
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.');
|
|
162
|
+
newSchema = set(newSchema, 'response.422', response422);
|
|
163
|
+
if (missingResponse) {
|
|
164
|
+
newSchema.response['200'] = {
|
|
165
|
+
description: 'Success. Return type has not been specified.',
|
|
166
|
+
type: 'object'
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
route.schema = newSchema;
|
|
170
|
+
});
|
|
171
|
+
this.app.addHook('preValidation', (req, res, done) => {
|
|
172
|
+
if (req.body != null && req.routeOptions.schema?.body)
|
|
173
|
+
destroyNulls(req.body);
|
|
174
|
+
done();
|
|
175
|
+
});
|
|
176
|
+
// use Ajv to validate responses instead of @fastify/json-fast-stringify since ajv does
|
|
177
|
+
// a better job with recursive types and we don't want to have different behavior between
|
|
178
|
+
// input and output validation
|
|
179
|
+
const ajv = new Ajv(config.ajv.customOptions);
|
|
180
|
+
for (const pluginConfig of config.ajv.plugins ?? []) {
|
|
181
|
+
const [plugin, opts] = toArray(pluginConfig);
|
|
182
|
+
plugin(ajv, opts); // eslint-disable-line @typescript-eslint/no-unsafe-call -- plugin type comes from fastify's ajv config
|
|
183
|
+
}
|
|
184
|
+
this.app.setSerializerCompiler(route => {
|
|
185
|
+
const schema = route.schema;
|
|
186
|
+
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
|
|
187
|
+
return data => {
|
|
188
|
+
/**
|
|
189
|
+
* Ajv unfortunately treats optional properties as non-nullable, so they're allowed to
|
|
190
|
+
* be undefined but not allowed to be null. Worse, with `coerceTypes`, null will be converted
|
|
191
|
+
* to empty string or 0 or false. This is silly behavior, so we're converting all nulls to
|
|
192
|
+
* undefined before we validate.
|
|
193
|
+
*/
|
|
194
|
+
if (schema != null)
|
|
195
|
+
destroyNulls(stringifyDates(data)); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- schema can be undefined at runtime
|
|
196
|
+
if (!validate(data))
|
|
197
|
+
throw new Error('Output validation failed. ' + validate.errors?.[0].instancePath + ': ' + validate.errors?.[0].message);
|
|
198
|
+
return JSON.stringify(data);
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
this.app.addHook('onRequest', (req, res, done) => {
|
|
202
|
+
res.extraLogInfo = {};
|
|
203
|
+
req.originChecker = this.originChecker;
|
|
204
|
+
done();
|
|
205
|
+
});
|
|
206
|
+
if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
|
|
207
|
+
this.originChecker.setValidOrigins([...(config.validOrigins ?? []), ...(process.env.VALID_ORIGINS?.split(',') ?? [])]);
|
|
208
|
+
this.originChecker.setValidOriginHosts([...(config.validOriginHosts ?? []), ...(process.env.VALID_ORIGIN_HOSTS?.split(',') ?? [])]);
|
|
209
|
+
this.originChecker.setValidOriginSuffixes([...(config.validOriginSuffixes ?? []), ...(process.env.VALID_ORIGIN_SUFFIXES?.split(',') ?? [])]);
|
|
210
|
+
this.app.addHook('onRequest', async (req, res) => {
|
|
211
|
+
if (!req.headers.origin)
|
|
212
|
+
return;
|
|
213
|
+
const passed = (req.headers.origin === 'null'
|
|
214
|
+
? process.env.NODE_ENV === 'development'
|
|
215
|
+
: this.originChecker.check(req.headers.origin, req.hostname)) || !!config.checkOrigin?.(req);
|
|
216
|
+
if (!passed) {
|
|
217
|
+
await res.status(403).send('Origin check failed. Suspected XSRF attack.');
|
|
218
|
+
return await res;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
void res.header('Access-Control-Allow-Origin', req.headers.origin);
|
|
222
|
+
void res.header('Access-Control-Max-Age', '600'); // ask browser to skip pre-flights for 10 minutes after a yes
|
|
223
|
+
if (req.headers['access-control-request-method'])
|
|
224
|
+
void res.header('access-control-allow-methods', req.headers['access-control-request-method']);
|
|
225
|
+
if (req.headers['access-control-request-headers'])
|
|
226
|
+
void res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
this.app.options('*', async (req, res) => {
|
|
230
|
+
await res.send();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (config.authenticate) {
|
|
234
|
+
const authenticatedMethods = {
|
|
235
|
+
GET: true,
|
|
236
|
+
POST: true,
|
|
237
|
+
PUT: true,
|
|
238
|
+
PATCH: true,
|
|
239
|
+
DELETE: true
|
|
240
|
+
};
|
|
241
|
+
this.app.addHook('onRequest', async (req, res) => {
|
|
242
|
+
if (!authenticatedMethods[req.method] || isBlank(req.routeOptions.url) || req.routeOptions.url === '/health' || req.routeOptions.url === '/.uaService' || (this.swaggerEndpoint && req.routeOptions.url.startsWith(this.swaggerEndpoint)))
|
|
243
|
+
return;
|
|
244
|
+
try {
|
|
245
|
+
req.auth = await config.authenticate(req);
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
await res.status(401).send('Failed to authenticate.');
|
|
249
|
+
return await res;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
this.app.addHook('onSend', this.https && process.env.NODE_ENV !== 'development'
|
|
254
|
+
? async (_, resp) => {
|
|
255
|
+
void resp.removeHeader('X-Powered-By');
|
|
256
|
+
void resp.header('Strict-Transport-Security', 'max-age=31536000');
|
|
257
|
+
if (resp.getHeader('content-type') === 'text/html')
|
|
258
|
+
void resp.type('text/html; charset=utf-8');
|
|
259
|
+
}
|
|
260
|
+
: async (_, resp) => {
|
|
261
|
+
void resp.removeHeader('X-Powered-By');
|
|
262
|
+
if (resp.getHeader('content-type') === 'text/html')
|
|
263
|
+
void resp.type('text/html; charset=utf-8');
|
|
264
|
+
});
|
|
265
|
+
this.app.setNotFoundHandler((req, res) => { void res.status(404).send('Not Found.'); });
|
|
266
|
+
this.app.setErrorHandler(async (err, req, res) => {
|
|
267
|
+
req.log.warn(err);
|
|
268
|
+
for (const errorHandler of this.errorHandlers) {
|
|
269
|
+
if (!res.sent)
|
|
270
|
+
await errorHandler(err, req, res);
|
|
271
|
+
}
|
|
272
|
+
if (!res.sent) {
|
|
273
|
+
if (err instanceof ValidationError) {
|
|
274
|
+
await res.status(err.statusCode).send({ success: false, messages: [{ message: err.message, path: err.path, type: err.type ?? 'error' }] });
|
|
275
|
+
}
|
|
276
|
+
else if (err instanceof ValidationErrors) {
|
|
277
|
+
await res.status(err.statusCode).send({ success: false, messages: err.errors });
|
|
278
|
+
}
|
|
279
|
+
else if (err instanceof HttpError) {
|
|
280
|
+
await res.status(err.statusCode).send(err.message);
|
|
281
|
+
}
|
|
282
|
+
else if (err.code === 'FST_ERR_VALIDATION') {
|
|
283
|
+
const developerErrors = [];
|
|
284
|
+
const userErrors = [];
|
|
285
|
+
for (const v of err.validation ?? []) {
|
|
286
|
+
if (v.keyword === 'errorMessage') {
|
|
287
|
+
for (const ov of v.params.errors) {
|
|
288
|
+
if (['type', 'additionalProperties', 'minProperties'].includes(ov.keyword))
|
|
289
|
+
developerErrors.push({ ...ov, message: v.message });
|
|
290
|
+
else if (ov.keyword === 'required')
|
|
291
|
+
userErrors.push({ ...ov, message: 'This field is required.' });
|
|
292
|
+
else
|
|
293
|
+
userErrors.push({ ...ov, message: v.message });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else if (['type', 'additionalProperties', 'minProperties'].includes(v.keyword))
|
|
297
|
+
developerErrors.push(v);
|
|
298
|
+
else if (v.keyword === 'required')
|
|
299
|
+
userErrors.push({ ...v, message: 'This field is required.' });
|
|
300
|
+
else
|
|
301
|
+
userErrors.push(v);
|
|
302
|
+
}
|
|
303
|
+
if (userErrors.length)
|
|
304
|
+
await res.status(422).send({ success: false, messages: userErrors.map(fstValidationToMessage) });
|
|
305
|
+
else
|
|
306
|
+
await res.status(400).send(developerErrors.map(fstValidationToMessage));
|
|
307
|
+
}
|
|
308
|
+
else if (err.statusCode) {
|
|
309
|
+
await res.status(err.statusCode).send(new HttpError(err.statusCode).message);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
await res.status(500).send('Internal Server Error.');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
this.app.get('/health', { logLevel: 'warn' }, async (req, res) => {
|
|
317
|
+
if (this.shuttingDown) {
|
|
318
|
+
res.log.warn('Returning 503 on /health because we are shutting down/restarting.');
|
|
319
|
+
void res.status(503);
|
|
320
|
+
return 'MAINTENANCE';
|
|
321
|
+
}
|
|
322
|
+
else if (this.healthMessage) {
|
|
323
|
+
res.log.error(this.healthMessage);
|
|
324
|
+
void res.status(500);
|
|
325
|
+
return this.healthMessage;
|
|
326
|
+
}
|
|
327
|
+
else if (this.healthCallback) {
|
|
328
|
+
const resp = await this.healthCallback();
|
|
329
|
+
const [status, msg] = typeof resp === 'string' ? [500, resp] : [resp?.status, resp?.message];
|
|
330
|
+
if (!!msg || !!status) {
|
|
331
|
+
res.log.error(resp, 'Health check callback failed.');
|
|
332
|
+
void res.status(status ?? 500);
|
|
333
|
+
return msg ?? 'FAIL';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return 'OK';
|
|
337
|
+
});
|
|
338
|
+
this.sigHandler = () => {
|
|
339
|
+
this.close().then(() => {
|
|
340
|
+
process.exit();
|
|
341
|
+
}).catch((e) => { this.app.log.error(e, 'Error during shutdown'); });
|
|
342
|
+
};
|
|
343
|
+
process.on('SIGTERM', this.sigHandler);
|
|
344
|
+
process.on('SIGINT', this.sigHandler);
|
|
345
|
+
}
|
|
346
|
+
async start(port) {
|
|
347
|
+
const customPort = port ?? parseInt(process.env.PORT ?? '0', 10);
|
|
348
|
+
await this.app.ready();
|
|
349
|
+
if (this.swaggerEndpoint)
|
|
350
|
+
this.app.swagger();
|
|
351
|
+
if (customPort) {
|
|
352
|
+
await this.app.listen({ port: customPort, host: '0.0.0.0' });
|
|
353
|
+
}
|
|
354
|
+
else if (this.https) {
|
|
355
|
+
// redirect 80 to 443
|
|
356
|
+
http.createServer((req, res) => {
|
|
357
|
+
res.writeHead(301, { Location: 'https://' + (req.headers.host?.replace(/:\d+$/v, '') ?? '') + (req.url ?? '') });
|
|
358
|
+
res.end();
|
|
359
|
+
}).listen(80);
|
|
360
|
+
await this.app.listen({ port: 443, host: '0.0.0.0' });
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
await this.app.listen({ port: 80, host: '0.0.0.0' });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
addErrorHandler(handler) {
|
|
367
|
+
this.errorHandlers.push(handler);
|
|
368
|
+
}
|
|
369
|
+
setUnhealthy(message) {
|
|
370
|
+
this.healthMessage = message;
|
|
371
|
+
}
|
|
372
|
+
setHealthy() {
|
|
373
|
+
this.healthMessage = undefined;
|
|
374
|
+
}
|
|
375
|
+
setValidOrigins(origins) {
|
|
376
|
+
this.originChecker.setValidOrigins(origins);
|
|
377
|
+
}
|
|
378
|
+
setValidOriginHosts(hosts) {
|
|
379
|
+
this.originChecker.setValidOriginHosts(hosts);
|
|
380
|
+
}
|
|
381
|
+
setValidOriginSuffixes(suffixes) {
|
|
382
|
+
this.originChecker.setValidOriginSuffixes(suffixes);
|
|
383
|
+
}
|
|
384
|
+
async swagger(opts) {
|
|
385
|
+
let openapi = opts?.openapi ?? {};
|
|
386
|
+
if (this.config.authenticate != null) {
|
|
387
|
+
openapi = set(openapi, 'components.securitySchemes', {
|
|
388
|
+
unifiedAuth: {
|
|
389
|
+
type: 'http',
|
|
390
|
+
scheme: 'bearer',
|
|
391
|
+
bearerFormat: 'JWT',
|
|
392
|
+
description: `Enter a token obtained from the TxState Unified Authentication service. An easy way to do
|
|
393
|
+
this is log into this application and use dev tools to pull your token from the Authorization header.`
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
// Apply the security globally to all operations
|
|
397
|
+
openapi.security = [{ unifiedAuth: [] }];
|
|
398
|
+
}
|
|
399
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return -- findRefs traverses arbitrary JSON schema objects */
|
|
400
|
+
function findRefs(obj, id) {
|
|
401
|
+
if (obj == null)
|
|
402
|
+
return undefined;
|
|
403
|
+
if (obj.$id?.length)
|
|
404
|
+
id = obj.$id;
|
|
405
|
+
if (obj.$ref === '#' && id?.length) {
|
|
406
|
+
obj.type = 'string';
|
|
407
|
+
obj.enum = [id];
|
|
408
|
+
delete obj.$ref;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
for (const val of Object.values(obj)) {
|
|
412
|
+
if (typeof val === 'object' && !(val instanceof Date))
|
|
413
|
+
findRefs(val, id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return obj;
|
|
417
|
+
}
|
|
418
|
+
await this.app.register(swagger, {
|
|
419
|
+
openapi,
|
|
420
|
+
transform(transformArgs) {
|
|
421
|
+
const newSchema = findRefs(clone(transformArgs.schema));
|
|
422
|
+
return { ...transformArgs, schema: newSchema };
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
/* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
|
|
426
|
+
this.swaggerEndpoint = opts?.path ?? opts?.ui?.routePrefix ?? '/docs';
|
|
427
|
+
await this.app.register(swaggerUI, { ...opts?.ui, routePrefix: this.swaggerEndpoint });
|
|
428
|
+
}
|
|
429
|
+
async close(softSeconds) {
|
|
430
|
+
if (typeof softSeconds === 'undefined')
|
|
431
|
+
softSeconds = parseInt(process.env.LOAD_BALANCE_TIMEOUT ?? '0', 10);
|
|
432
|
+
process.removeListener('SIGTERM', this.sigHandler);
|
|
433
|
+
process.removeListener('SIGINT', this.sigHandler);
|
|
434
|
+
if (softSeconds) {
|
|
435
|
+
this.shuttingDown = true;
|
|
436
|
+
await sleep(softSeconds);
|
|
437
|
+
}
|
|
438
|
+
await this.app.close();
|
|
439
|
+
}
|
|
440
|
+
}
|
package/lib/unified-auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type IssuerConfig, type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from '.';
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import { type IssuerConfig, type FastifyInstanceTyped, type FastifyTxStateAuthInfo } from './server.ts';
|
|
3
3
|
export interface IssuerConfigRaw extends Omit<IssuerConfig, 'validateUrl' | 'logoutUrl'> {
|
|
4
4
|
validateUrl?: string;
|
|
5
5
|
logoutUrl?: string;
|