fastify-txstate 3.2.10 → 3.2.12
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/analytics.js +9 -10
- package/lib/error.js +2 -0
- package/lib/index.js +36 -40
- package/lib/unified-auth.js +60 -47
- package/lib-esm/index.d.ts +4 -2
- package/package.json +2 -2
package/lib/analytics.js
CHANGED
|
@@ -17,6 +17,7 @@ class AnalyticsClient {
|
|
|
17
17
|
}
|
|
18
18
|
exports.AnalyticsClient = AnalyticsClient;
|
|
19
19
|
class LoggingAnalyticsClient extends AnalyticsClient {
|
|
20
|
+
logger;
|
|
20
21
|
constructor(logger) {
|
|
21
22
|
super();
|
|
22
23
|
this.logger = logger;
|
|
@@ -28,30 +29,29 @@ class LoggingAnalyticsClient extends AnalyticsClient {
|
|
|
28
29
|
}
|
|
29
30
|
exports.LoggingAnalyticsClient = LoggingAnalyticsClient;
|
|
30
31
|
class ElasticAnalyticsClient extends AnalyticsClient {
|
|
32
|
+
elasticClient;
|
|
31
33
|
constructor() {
|
|
32
|
-
var _a, _b;
|
|
33
34
|
super();
|
|
34
35
|
this.elasticClient = new elasticsearch_1.default.Client({
|
|
35
36
|
node: process.env.ELASTICSEARCH_URL,
|
|
36
37
|
auth: {
|
|
37
|
-
username:
|
|
38
|
-
password:
|
|
38
|
+
username: process.env.ELASTICSEARCH_USER ?? 'elastic',
|
|
39
|
+
password: process.env.ELASTICSEARCH_PASS ?? 'not_provided'
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
async push(events) {
|
|
43
44
|
if (events.length)
|
|
44
|
-
await this.elasticClient.bulk({ body: events.reduce((acc, event) => {
|
|
45
|
+
await this.elasticClient.bulk({ body: events.reduce((acc, event) => { acc.push({ index: { _index: process.env.ELASTICSEARCH_USEREVENTS_INDEX ?? 'interaction-analytics' } }, event); return acc; }, []) });
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
exports.ElasticAnalyticsClient = ElasticAnalyticsClient;
|
|
48
49
|
function analyticsPlugin(fastify, opts, done) {
|
|
49
|
-
var _a;
|
|
50
50
|
const environment = process.env.NODE_ENV;
|
|
51
51
|
if ((0, txstate_utils_1.isBlank)(environment))
|
|
52
52
|
throw new Error('Must set NODE_ENV when reporting analytics.');
|
|
53
53
|
const eventQueue = [];
|
|
54
|
-
const analyticsClient =
|
|
54
|
+
const analyticsClient = opts.analyticsClient ?? ((0, txstate_utils_1.isBlank)(process.env.ELASTICSEARCH_URL)
|
|
55
55
|
? environment === 'development'
|
|
56
56
|
? new AnalyticsClient()
|
|
57
57
|
: new LoggingAnalyticsClient(fastify.log)
|
|
@@ -93,14 +93,13 @@ function analyticsPlugin(fastify, opts, done) {
|
|
|
93
93
|
}
|
|
94
94
|
setTimeout(() => { void flushQueue(); }, 5000);
|
|
95
95
|
function queueEvents(auth, headers, remoteIp, events) {
|
|
96
|
-
var _a, _b, _c;
|
|
97
96
|
for (const event of events) {
|
|
98
97
|
eventQueue.push({
|
|
99
98
|
event,
|
|
100
99
|
remoteIp,
|
|
101
|
-
ua:
|
|
100
|
+
ua: headers['user-agent'] ?? '',
|
|
102
101
|
time: new Date().toISOString(),
|
|
103
|
-
gaCookie:
|
|
102
|
+
gaCookie: headers.cookie?.replace(/^.*?(?:_ga=([^;]+))?.*$/, '$1') ?? '',
|
|
104
103
|
auth
|
|
105
104
|
});
|
|
106
105
|
}
|
|
@@ -109,7 +108,7 @@ function analyticsPlugin(fastify, opts, done) {
|
|
|
109
108
|
const { auth } = req;
|
|
110
109
|
if (opts.authorize && !opts.authorize(req))
|
|
111
110
|
throw new _1.HttpError(401);
|
|
112
|
-
queueEvents(auth
|
|
111
|
+
queueEvents(auth ?? { username: 'unauthenticated' }, req.headers, req.ip, req.body);
|
|
113
112
|
res.statusCode = 202;
|
|
114
113
|
return 'OK';
|
|
115
114
|
});
|
package/lib/error.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.fstValidationToMessage = exports.FailedValidationError = exports.HttpError = void 0;
|
|
4
4
|
const http_status_codes_1 = require("http-status-codes");
|
|
5
5
|
class HttpError extends Error {
|
|
6
|
+
statusCode;
|
|
6
7
|
constructor(statusCode, message) {
|
|
7
8
|
if (!message) {
|
|
8
9
|
if (statusCode === 401)
|
|
@@ -18,6 +19,7 @@ class HttpError extends Error {
|
|
|
18
19
|
}
|
|
19
20
|
exports.HttpError = HttpError;
|
|
20
21
|
class FailedValidationError extends HttpError {
|
|
22
|
+
errors;
|
|
21
23
|
constructor(errors) {
|
|
22
24
|
super(422, 'Validation failure.');
|
|
23
25
|
this.errors = errors;
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,6 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
28
28
|
const node_http_1 = __importDefault(require("node:http"));
|
|
29
29
|
const txstate_utils_1 = require("txstate-utils");
|
|
30
30
|
const error_1 = require("./error");
|
|
31
|
-
const unified_auth_1 = require("./unified-auth");
|
|
32
31
|
exports.devLogger = {
|
|
33
32
|
level: 'info',
|
|
34
33
|
info: (msg) => { console.info(msg.req ? `${msg.req.method} ${msg.req.url}` : msg.res ? `${msg.res.statusCode} - ${msg.responseTime}` : msg); },
|
|
@@ -52,27 +51,30 @@ exports.prodLogger = {
|
|
|
52
51
|
};
|
|
53
52
|
},
|
|
54
53
|
res(res) {
|
|
55
|
-
var _a, _b, _c, _d;
|
|
56
54
|
return {
|
|
57
55
|
statusCode: res.statusCode,
|
|
58
|
-
url:
|
|
59
|
-
length: Number((0, txstate_utils_1.toArray)(
|
|
56
|
+
url: res.request?.url.replace(/(token|unifiedJwt)=[\w.]+/i, '$1=redacted'),
|
|
57
|
+
length: Number((0, txstate_utils_1.toArray)(res.getHeader?.('content-length'))[0]),
|
|
60
58
|
...res.extraLogInfo,
|
|
61
|
-
auth:
|
|
59
|
+
auth: res.request?.auth ?? res.extraLogInfo.auth
|
|
62
60
|
};
|
|
63
61
|
}
|
|
64
62
|
}
|
|
65
63
|
};
|
|
66
64
|
class Server {
|
|
65
|
+
config;
|
|
66
|
+
https = false;
|
|
67
|
+
errorHandlers = [];
|
|
68
|
+
healthMessage;
|
|
69
|
+
healthCallback;
|
|
70
|
+
shuttingDown = false;
|
|
71
|
+
sigHandler;
|
|
72
|
+
validOrigins = {};
|
|
73
|
+
validOriginHosts = {};
|
|
74
|
+
validOriginSuffixes = new Set();
|
|
75
|
+
app;
|
|
67
76
|
constructor(config = {}) {
|
|
68
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
69
77
|
this.config = config;
|
|
70
|
-
this.https = false;
|
|
71
|
-
this.errorHandlers = [];
|
|
72
|
-
this.shuttingDown = false;
|
|
73
|
-
this.validOrigins = {};
|
|
74
|
-
this.validOriginHosts = {};
|
|
75
|
-
this.validOriginSuffixes = new Set();
|
|
76
78
|
try {
|
|
77
79
|
const key = node_fs_1.default.readFileSync('/securekeys/private.key');
|
|
78
80
|
const cert = node_fs_1.default.readFileSync('/securekeys/cert.pem');
|
|
@@ -101,16 +103,15 @@ class Server {
|
|
|
101
103
|
else
|
|
102
104
|
config.trustProxy = process.env.TRUST_PROXY;
|
|
103
105
|
}
|
|
104
|
-
config.ajv = { ...config.ajv, plugins: [...(
|
|
106
|
+
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 } };
|
|
105
107
|
this.healthCallback = config.checkHealth;
|
|
106
108
|
this.app = (0, fastify_1.fastify)(config);
|
|
107
109
|
this.app.addHook('onRoute', route => {
|
|
108
|
-
|
|
109
|
-
if (!((_a = route.schema) === null || _a === void 0 ? void 0 : _a.body))
|
|
110
|
+
if (!route.schema?.body)
|
|
110
111
|
return;
|
|
111
|
-
const missingResponse =
|
|
112
|
+
const missingResponse = route.schema?.response == null;
|
|
112
113
|
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.');
|
|
113
|
-
let newSchema = (0, txstate_utils_1.set)(
|
|
114
|
+
let newSchema = (0, txstate_utils_1.set)(route.schema ?? {}, 'response.400', response400);
|
|
114
115
|
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.');
|
|
115
116
|
newSchema = (0, txstate_utils_1.set)(newSchema, 'response.422', response422);
|
|
116
117
|
if (missingResponse) {
|
|
@@ -130,15 +131,13 @@ class Server {
|
|
|
130
131
|
* convert all nulls to undefined before fastify validates.
|
|
131
132
|
*/
|
|
132
133
|
this.app.addHook('preSerialization', async (req, res, payload) => {
|
|
133
|
-
|
|
134
|
-
return ((_a = req.routeSchema) === null || _a === void 0 ? void 0 : _a.response) ? (0, txstate_utils_1.destroyNulls)(payload) : payload;
|
|
134
|
+
return req.routeSchema?.response ? (0, txstate_utils_1.destroyNulls)(payload) : payload;
|
|
135
135
|
});
|
|
136
136
|
if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
|
|
137
|
-
this.setValidOrigins([...(
|
|
138
|
-
this.setValidOriginHosts([...(
|
|
139
|
-
this.setValidOriginSuffixes([...(
|
|
137
|
+
this.setValidOrigins([...(config.validOrigins ?? []), ...(process.env.VALID_ORIGINS?.split(',') ?? [])]);
|
|
138
|
+
this.setValidOriginHosts([...(config.validOriginHosts ?? []), ...(process.env.VALID_ORIGIN_HOSTS?.split(',') ?? [])]);
|
|
139
|
+
this.setValidOriginSuffixes([...(config.validOriginSuffixes ?? []), ...(process.env.VALID_ORIGIN_SUFFIXES?.split(',') ?? [])]);
|
|
140
140
|
this.app.addHook('preHandler', async (req, res) => {
|
|
141
|
-
var _a;
|
|
142
141
|
res.extraLogInfo = {};
|
|
143
142
|
if (!req.headers.origin)
|
|
144
143
|
return;
|
|
@@ -160,7 +159,7 @@ class Server {
|
|
|
160
159
|
}
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
|
-
if (!passed &&
|
|
162
|
+
if (!passed && config.checkOrigin?.(req))
|
|
164
163
|
passed = true;
|
|
165
164
|
if (!passed) {
|
|
166
165
|
await res.status(403).send('Origin check failed. Suspected XSRF attack.');
|
|
@@ -169,6 +168,8 @@ class Server {
|
|
|
169
168
|
else {
|
|
170
169
|
void res.header('Access-Control-Allow-Origin', req.headers.origin);
|
|
171
170
|
void res.header('Access-Control-Max-Age', '600'); // ask browser to skip pre-flights for 10 minutes after a yes
|
|
171
|
+
if (req.headers['access-control-request-method'])
|
|
172
|
+
void res.header('access-control-allow-methods', req.headers['access-control-request-method']);
|
|
172
173
|
if (req.headers['access-control-request-headers'])
|
|
173
174
|
void res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
|
|
174
175
|
}
|
|
@@ -211,7 +212,6 @@ class Server {
|
|
|
211
212
|
});
|
|
212
213
|
this.app.setNotFoundHandler((req, res) => { void res.status(404).send('Not Found.'); });
|
|
213
214
|
this.app.setErrorHandler(async (err, req, res) => {
|
|
214
|
-
var _a;
|
|
215
215
|
req.log.warn(err);
|
|
216
216
|
for (const errorHandler of this.errorHandlers) {
|
|
217
217
|
if (!res.sent)
|
|
@@ -227,7 +227,7 @@ class Server {
|
|
|
227
227
|
else if (err.code === 'FST_ERR_VALIDATION') {
|
|
228
228
|
const developerErrors = [];
|
|
229
229
|
const userErrors = [];
|
|
230
|
-
for (const v of
|
|
230
|
+
for (const v of err.validation ?? []) {
|
|
231
231
|
if (v.keyword === 'errorMessage') {
|
|
232
232
|
for (const ov of v.params.errors) {
|
|
233
233
|
if (['type', 'additionalProperties'].includes(ov.keyword))
|
|
@@ -269,11 +269,11 @@ class Server {
|
|
|
269
269
|
}
|
|
270
270
|
else if (this.healthCallback) {
|
|
271
271
|
const resp = await this.healthCallback();
|
|
272
|
-
const [status, msg] = typeof resp === 'string' ? [500, resp] : [resp
|
|
272
|
+
const [status, msg] = typeof resp === 'string' ? [500, resp] : [resp?.status, resp?.message];
|
|
273
273
|
if (!!msg || !!status) {
|
|
274
274
|
res.log.info(resp, 'Health check callback failed.');
|
|
275
|
-
void res.status(status
|
|
276
|
-
return msg
|
|
275
|
+
void res.status(status ?? 500);
|
|
276
|
+
return msg ?? 'FAIL';
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
return 'OK';
|
|
@@ -287,18 +287,16 @@ class Server {
|
|
|
287
287
|
process.on('SIGINT', this.sigHandler);
|
|
288
288
|
}
|
|
289
289
|
async start(port) {
|
|
290
|
-
|
|
291
|
-
const customPort = port !== null && port !== void 0 ? port : parseInt((_a = process.env.PORT) !== null && _a !== void 0 ? _a : '0');
|
|
290
|
+
const customPort = port ?? parseInt(process.env.PORT ?? '0');
|
|
292
291
|
await this.app.ready();
|
|
293
|
-
|
|
292
|
+
this.app.swagger?.();
|
|
294
293
|
if (customPort) {
|
|
295
294
|
await this.app.listen({ port: customPort, host: '0.0.0.0' });
|
|
296
295
|
}
|
|
297
296
|
else if (this.https) {
|
|
298
297
|
// redirect 80 to 443
|
|
299
298
|
node_http_1.default.createServer((req, res) => {
|
|
300
|
-
|
|
301
|
-
res.writeHead(301, { Location: 'https://' + ((_c = (_b = (_a = req === null || req === void 0 ? void 0 : req.headers) === null || _a === void 0 ? void 0 : _a.host) === null || _b === void 0 ? void 0 : _b.replace(/:\d+$/, '')) !== null && _c !== void 0 ? _c : '') + ((_d = req.url) !== null && _d !== void 0 ? _d : '') });
|
|
299
|
+
res.writeHead(301, { Location: 'https://' + (req?.headers?.host?.replace(/:\d+$/, '') ?? '') + (req.url ?? '') });
|
|
302
300
|
res.end();
|
|
303
301
|
}).listen(80);
|
|
304
302
|
await this.app.listen({ port: 443, host: '0.0.0.0' });
|
|
@@ -328,9 +326,8 @@ class Server {
|
|
|
328
326
|
this.validOriginSuffixes.add(s);
|
|
329
327
|
}
|
|
330
328
|
async swagger(opts) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (this.config.authenticate === unified_auth_1.unifiedAuthenticate) {
|
|
329
|
+
let openapi = opts?.openapi ?? {};
|
|
330
|
+
if (this.config.authenticate != null) {
|
|
334
331
|
openapi = (0, txstate_utils_1.set)(openapi, 'components.securitySchemes', {
|
|
335
332
|
unifiedAuth: {
|
|
336
333
|
type: 'http',
|
|
@@ -344,12 +341,11 @@ this is log into this application and use dev tools to pull your token from the
|
|
|
344
341
|
openapi.security = [{ unifiedAuth: [] }];
|
|
345
342
|
}
|
|
346
343
|
await this.app.register(swagger_1.default, { openapi });
|
|
347
|
-
await this.app.register(swagger_ui_1.default, { ...opts
|
|
344
|
+
await this.app.register(swagger_ui_1.default, { ...opts?.ui, routePrefix: opts?.path ?? opts?.ui?.routePrefix ?? '/docs' });
|
|
348
345
|
}
|
|
349
346
|
async close(softSeconds) {
|
|
350
|
-
var _a;
|
|
351
347
|
if (typeof softSeconds === 'undefined')
|
|
352
|
-
softSeconds = parseInt(
|
|
348
|
+
softSeconds = parseInt(process.env.LOAD_BALANCE_TIMEOUT ?? '0');
|
|
353
349
|
process.removeListener('SIGTERM', this.sigHandler);
|
|
354
350
|
process.removeListener('SIGINT', this.sigHandler);
|
|
355
351
|
if (softSeconds) {
|
package/lib/unified-auth.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var _a;
|
|
3
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
3
|
exports.unifiedAuthenticateAll = exports.unifiedAuthenticate = void 0;
|
|
5
4
|
const crypto_1 = require("crypto");
|
|
6
5
|
const jose_1 = require("jose");
|
|
7
6
|
const txstate_utils_1 = require("txstate-utils");
|
|
8
7
|
function cleanPem(secretOrPem) {
|
|
9
|
-
return secretOrPem
|
|
8
|
+
return secretOrPem?.replace(/(-+BEGIN [\w\s]+ KEY-+)\s*(.*?)\s*(-+END [\w\s]+ KEY-+)/, '$1\n$2\n$3');
|
|
10
9
|
}
|
|
11
10
|
class MockContext {
|
|
11
|
+
auth;
|
|
12
12
|
constructor(auth) {
|
|
13
13
|
this.auth = auth;
|
|
14
14
|
}
|
|
@@ -18,6 +18,36 @@ class MockContext {
|
|
|
18
18
|
static init() { }
|
|
19
19
|
}
|
|
20
20
|
class Context extends MockContext {
|
|
21
|
+
authPromise;
|
|
22
|
+
static jwtVerifyKey;
|
|
23
|
+
static issuerKeys = new Map();
|
|
24
|
+
static issuerConfig = new Map();
|
|
25
|
+
static tokenCache = new txstate_utils_1.Cache(async (token, { req, ctx }) => {
|
|
26
|
+
// `this` is always the Context class, even if we are making instances of a subclass of Context
|
|
27
|
+
// we need to get the instance's constructor instead in case it has overridden one of our
|
|
28
|
+
// static methods/variables
|
|
29
|
+
const ctxStatic = ctx.constructor;
|
|
30
|
+
const logger = req?.log ?? console;
|
|
31
|
+
let verifyKey = Context.jwtVerifyKey;
|
|
32
|
+
try {
|
|
33
|
+
const claims = (0, jose_1.decodeJwt)(token);
|
|
34
|
+
if (claims.iss && ctxStatic.issuerKeys.has(claims.iss))
|
|
35
|
+
verifyKey = ctxStatic.issuerKeys.get(claims.iss);
|
|
36
|
+
if (!verifyKey) {
|
|
37
|
+
logger.info(`Received token with issuer: ${claims.iss} but JWT secret could not be found. The server may be misconfigured or the user may have presented a JWT from an untrusted issuer.`);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
await ctxStatic.validateToken?.(token, ctxStatic.issuerConfig.get(claims.iss), claims);
|
|
41
|
+
const { payload } = await (0, jose_1.jwtVerify)(token, verifyKey);
|
|
42
|
+
return await ctx.authFromPayload(payload);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
// squelch errors about bad tokens, we can already see the 401 in the log
|
|
46
|
+
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED')
|
|
47
|
+
logger.error(e);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}, { freshseconds: 10 });
|
|
21
51
|
constructor(req) {
|
|
22
52
|
super(undefined);
|
|
23
53
|
this.authPromise = this.authFromReq(req);
|
|
@@ -26,31 +56,31 @@ class Context extends MockContext {
|
|
|
26
56
|
this.auth = await this.authPromise;
|
|
27
57
|
return this.auth;
|
|
28
58
|
}
|
|
59
|
+
static hasInitialized = false;
|
|
29
60
|
static init() {
|
|
30
|
-
var _b;
|
|
31
61
|
if (this.hasInitialized)
|
|
32
62
|
return;
|
|
33
63
|
this.hasInitialized = true;
|
|
34
64
|
let secret = cleanPem(process.env.JWT_SECRET_VERIFY);
|
|
35
65
|
if (secret != null) {
|
|
36
|
-
|
|
66
|
+
Context.jwtVerifyKey = (0, crypto_1.createPublicKey)(secret);
|
|
37
67
|
}
|
|
38
68
|
else {
|
|
39
69
|
secret = cleanPem(process.env.JWT_SECRET);
|
|
40
70
|
if (secret != null) {
|
|
41
71
|
try {
|
|
42
|
-
|
|
72
|
+
Context.jwtVerifyKey = (0, crypto_1.createPublicKey)(secret);
|
|
43
73
|
}
|
|
44
74
|
catch (e) {
|
|
45
75
|
console.info('JWT_SECRET was not a private key, treating it as symmetric.');
|
|
46
|
-
|
|
76
|
+
Context.jwtVerifyKey = (0, crypto_1.createSecretKey)(Buffer.from(secret, 'ascii'));
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
}
|
|
50
80
|
if (process.env.JWT_TRUSTED_ISSUERS) {
|
|
51
81
|
const issuers = (0, txstate_utils_1.toArray)(JSON.parse(process.env.JWT_TRUSTED_ISSUERS));
|
|
52
82
|
for (const issuer of issuers) {
|
|
53
|
-
this.issuerConfig.set(issuer.iss,
|
|
83
|
+
this.issuerConfig.set(issuer.iss, this.processIssuerConfig?.((0, txstate_utils_1.omit)(issuer, 'publicKey', 'secret')));
|
|
54
84
|
if (issuer.url)
|
|
55
85
|
this.issuerKeys.set(issuer.iss, (0, jose_1.createRemoteJWKSet)(new URL(issuer.url)));
|
|
56
86
|
else if (issuer.publicKey)
|
|
@@ -60,10 +90,27 @@ class Context extends MockContext {
|
|
|
60
90
|
}
|
|
61
91
|
}
|
|
62
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* If implemented, this method will be called on startup, once per configured issuer. It receives
|
|
95
|
+
* the issuer configuration from the JWT_TRUSTED_ISSUERS environment variable and allows you to manipulate
|
|
96
|
+
* the configuration before storing it.
|
|
97
|
+
*
|
|
98
|
+
* Once stored, whatever you create may be used in your custom validateToken method. For example,
|
|
99
|
+
* you might want to create an in-memory URL object with an issuer's URL so that it can be manipulated
|
|
100
|
+
* easily to send validation checks to the issuer.
|
|
101
|
+
*/
|
|
102
|
+
static processIssuerConfig;
|
|
103
|
+
/**
|
|
104
|
+
* If implemented, this method is called after a token's signature is checked and passes. You would
|
|
105
|
+
* typically implement this method to check whether the user has manually signed out, or the token has
|
|
106
|
+
* been otherwise deauthorized before its expiration date.
|
|
107
|
+
*
|
|
108
|
+
* If the token is not valid, this method should throw an error with an appropriate message.
|
|
109
|
+
*/
|
|
110
|
+
static validateToken;
|
|
63
111
|
tokenFromReq(req) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return m === null || m === void 0 ? void 0 : m[1];
|
|
112
|
+
const m = req?.headers.authorization?.match(/^bearer (.*)$/i);
|
|
113
|
+
return m?.[1];
|
|
67
114
|
}
|
|
68
115
|
async authFromReq(req) {
|
|
69
116
|
const token = this.tokenFromReq(req);
|
|
@@ -75,55 +122,22 @@ class Context extends MockContext {
|
|
|
75
122
|
return payload;
|
|
76
123
|
}
|
|
77
124
|
}
|
|
78
|
-
_a = Context;
|
|
79
|
-
Context.issuerKeys = new Map();
|
|
80
|
-
Context.issuerConfig = new Map();
|
|
81
|
-
Context.tokenCache = new txstate_utils_1.Cache(async (token, { req, ctx }) => {
|
|
82
|
-
var _b, _c;
|
|
83
|
-
// `this` is always the Context class, even if we are making instances of a subclass of Context
|
|
84
|
-
// we need to get the instance's constructor instead in case it has overridden one of our
|
|
85
|
-
// static methods/variables
|
|
86
|
-
const ctxStatic = ctx.constructor;
|
|
87
|
-
const logger = (_b = req === null || req === void 0 ? void 0 : req.log) !== null && _b !== void 0 ? _b : console;
|
|
88
|
-
let verifyKey = _a.jwtVerifyKey;
|
|
89
|
-
try {
|
|
90
|
-
const claims = (0, jose_1.decodeJwt)(token);
|
|
91
|
-
if (claims.iss && ctxStatic.issuerKeys.has(claims.iss))
|
|
92
|
-
verifyKey = ctxStatic.issuerKeys.get(claims.iss);
|
|
93
|
-
if (!verifyKey) {
|
|
94
|
-
logger.info(`Received token with issuer: ${claims.iss} but JWT secret could not be found. The server may be misconfigured or the user may have presented a JWT from an untrusted issuer.`);
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
await ((_c = ctxStatic.validateToken) === null || _c === void 0 ? void 0 : _c.call(ctxStatic, token, ctxStatic.issuerConfig.get(claims.iss), claims));
|
|
98
|
-
const { payload } = await (0, jose_1.jwtVerify)(token, verifyKey);
|
|
99
|
-
return await ctx.authFromPayload(payload);
|
|
100
|
-
}
|
|
101
|
-
catch (e) {
|
|
102
|
-
// squelch errors about bad tokens, we can already see the 401 in the log
|
|
103
|
-
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED')
|
|
104
|
-
logger.error(e);
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
}, { freshseconds: 10 });
|
|
108
|
-
Context.hasInitialized = false;
|
|
109
125
|
class TxStateUAuthContext extends Context {
|
|
110
126
|
static processIssuerConfig(config) {
|
|
111
|
-
var _b;
|
|
112
127
|
if (config.iss === 'unified-auth') {
|
|
113
|
-
config.validateUrl = new URL(
|
|
128
|
+
config.validateUrl = new URL(config.url ?? '');
|
|
114
129
|
config.validateUrl.pathname = '/validateToken';
|
|
115
130
|
}
|
|
116
131
|
return config;
|
|
117
132
|
}
|
|
118
133
|
static async validateToken(token, issuerConfig, claims) {
|
|
119
|
-
var _b;
|
|
120
134
|
if (claims.iss === 'unified-auth') {
|
|
121
135
|
const validateUrl = new URL(issuerConfig.validateUrl);
|
|
122
136
|
validateUrl.searchParams.set('unifiedJwt', token);
|
|
123
137
|
const resp = await fetch(validateUrl);
|
|
124
138
|
const validate = await resp.json();
|
|
125
139
|
if (!validate.valid)
|
|
126
|
-
throw new Error(
|
|
140
|
+
throw new Error(validate.reason ?? 'Your session has been ended on another device or in another browser tab/window. It\'s also possible your NetID is no longer active.');
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
async authFromPayload(payload) {
|
|
@@ -142,11 +156,10 @@ async function unifiedAuthenticate(req, ContextClass = TxStateUAuthContext) {
|
|
|
142
156
|
}
|
|
143
157
|
exports.unifiedAuthenticate = unifiedAuthenticate;
|
|
144
158
|
async function unifiedAuthenticateAll(req, ContextClass = TxStateUAuthContext) {
|
|
145
|
-
var _b;
|
|
146
159
|
ContextClass.init();
|
|
147
160
|
const ctx = new ContextClass(req);
|
|
148
161
|
const auth = await ctx.waitForAuth();
|
|
149
|
-
if (!
|
|
162
|
+
if (!auth?.username.length && !req.routeOptions.url?.startsWith('/docs'))
|
|
150
163
|
throw new Error('All requests require authentication.');
|
|
151
164
|
return auth;
|
|
152
165
|
}
|
package/lib-esm/index.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
|
|
|
77
77
|
*
|
|
78
78
|
* If this function throws, the client will receive a 401 response.
|
|
79
79
|
*/
|
|
80
|
-
authenticate?:
|
|
80
|
+
authenticate?: (req: FastifyRequest) => Promise<FastifyTxStateAuthInfo | undefined>;
|
|
81
81
|
}
|
|
82
82
|
declare module 'fastify' {
|
|
83
83
|
interface FastifyRequest {
|
|
@@ -114,6 +114,8 @@ export declare const devLogger: {
|
|
|
114
114
|
child(bindings: any, options?: any): any;
|
|
115
115
|
};
|
|
116
116
|
export declare const prodLogger: FastifyLoggerOptions;
|
|
117
|
+
export type FastifyInstanceTyped = FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, FastifyBaseLogger, JsonSchemaToTsProvider>;
|
|
118
|
+
export type TxServer = Server;
|
|
117
119
|
export default class Server {
|
|
118
120
|
protected config: FastifyTxStateOptions & {
|
|
119
121
|
http2?: true;
|
|
@@ -130,7 +132,7 @@ export default class Server {
|
|
|
130
132
|
protected validOrigins: Record<string, boolean>;
|
|
131
133
|
protected validOriginHosts: Record<string, boolean>;
|
|
132
134
|
protected validOriginSuffixes: Set<string>;
|
|
133
|
-
app:
|
|
135
|
+
app: FastifyInstanceTyped;
|
|
134
136
|
constructor(config?: FastifyTxStateOptions & {
|
|
135
137
|
http2?: true;
|
|
136
138
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-txstate",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.12",
|
|
4
4
|
"description": "A small wrapper for fastify providing a set of common conventions & utility functions we use.",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"@fastify/swagger-ui": "^3.0.0",
|
|
24
24
|
"@fastify/type-provider-json-schema-to-ts": "^3.0.0",
|
|
25
25
|
"@txstate-mws/fastify-shared": "^1.0.9",
|
|
26
|
+
"@types/ua-parser-js": ">=0.7.39",
|
|
26
27
|
"ajv-errors": "^3.0.0",
|
|
27
28
|
"ajv-formats": "^2.1.1",
|
|
28
29
|
"fastify": "^4.9.2",
|
|
@@ -36,7 +37,6 @@
|
|
|
36
37
|
"@types/chai": "^4.2.14",
|
|
37
38
|
"@types/mocha": "^10.0.0",
|
|
38
39
|
"@types/node": "^20.0.0",
|
|
39
|
-
"@types/ua-parser-js": ">=0.7.39",
|
|
40
40
|
"axios": "^1.6.8",
|
|
41
41
|
"chai": "^4.2.0",
|
|
42
42
|
"eslint-config-standard-with-typescript": "^43.0.0",
|