fastify-txstate 3.1.9 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analytics.d.ts +33 -0
- package/lib/analytics.js +115 -0
- package/lib/error.d.ts +10 -0
- package/lib/error.js +26 -0
- package/lib/index.d.ts +153 -0
- package/lib/index.js +112 -44
- package/lib/unified-auth.d.ts +61 -0
- package/lib/unified-auth.js +138 -0
- package/lib-esm/index.d.ts +75 -13
- package/lib-esm/index.js +5 -0
- package/package.json +26 -11
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type InteractionEvent } from '@txstate-mws/fastify-shared';
|
|
2
|
+
import type { FastifyBaseLogger, FastifyInstance } from 'fastify';
|
|
3
|
+
import { type IBrowser, type IDevice, type IOS } from 'ua-parser-js';
|
|
4
|
+
import { type FastifyTxStateAuthInfo } from '.';
|
|
5
|
+
export interface StoredInteractionEvent extends InteractionEvent {
|
|
6
|
+
'@timestamp': string;
|
|
7
|
+
appName: string;
|
|
8
|
+
environment: string;
|
|
9
|
+
browser: IBrowser;
|
|
10
|
+
device: IDevice;
|
|
11
|
+
os: IOS;
|
|
12
|
+
user: {
|
|
13
|
+
remoteip: string;
|
|
14
|
+
ga?: string;
|
|
15
|
+
} & FastifyTxStateAuthInfo;
|
|
16
|
+
}
|
|
17
|
+
export declare class AnalyticsClient {
|
|
18
|
+
push(events: StoredInteractionEvent[]): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export declare class LoggingAnalyticsClient extends AnalyticsClient {
|
|
21
|
+
protected logger: FastifyBaseLogger;
|
|
22
|
+
constructor(logger: FastifyBaseLogger);
|
|
23
|
+
push(events: StoredInteractionEvent[]): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare class ElasticAnalyticsClient extends AnalyticsClient {
|
|
26
|
+
private readonly elasticClient;
|
|
27
|
+
constructor();
|
|
28
|
+
push(events: StoredInteractionEvent[]): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare function analyticsPlugin(fastify: FastifyInstance, opts: {
|
|
31
|
+
appName: string;
|
|
32
|
+
analyticsClient?: AnalyticsClient;
|
|
33
|
+
}, done: (err?: Error) => void): void;
|
package/lib/analytics.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.analyticsPlugin = exports.ElasticAnalyticsClient = exports.LoggingAnalyticsClient = exports.AnalyticsClient = void 0;
|
|
7
|
+
const elasticsearch_1 = __importDefault(require("@elastic/elasticsearch"));
|
|
8
|
+
const fastify_shared_1 = require("@txstate-mws/fastify-shared");
|
|
9
|
+
const txstate_utils_1 = require("txstate-utils");
|
|
10
|
+
const ua_parser_js_1 = require("ua-parser-js");
|
|
11
|
+
class AnalyticsClient {
|
|
12
|
+
async push(events) {
|
|
13
|
+
for (const event of events)
|
|
14
|
+
console.info('analytics event:', JSON.stringify((0, txstate_utils_1.pick)(event, 'eventType', 'screen', 'action', 'target')));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.AnalyticsClient = AnalyticsClient;
|
|
18
|
+
class LoggingAnalyticsClient extends AnalyticsClient {
|
|
19
|
+
constructor(logger) {
|
|
20
|
+
super();
|
|
21
|
+
this.logger = logger;
|
|
22
|
+
}
|
|
23
|
+
async push(events) {
|
|
24
|
+
for (const event of events)
|
|
25
|
+
this.logger.info({ analyticsEvent: event });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.LoggingAnalyticsClient = LoggingAnalyticsClient;
|
|
29
|
+
class ElasticAnalyticsClient extends AnalyticsClient {
|
|
30
|
+
constructor() {
|
|
31
|
+
var _a, _b;
|
|
32
|
+
super();
|
|
33
|
+
this.elasticClient = new elasticsearch_1.default.Client({
|
|
34
|
+
node: process.env.ELASTICSEARCH_URL,
|
|
35
|
+
auth: {
|
|
36
|
+
username: (_a = process.env.ELASTICSEARCH_USER) !== null && _a !== void 0 ? _a : 'elastic',
|
|
37
|
+
password: (_b = process.env.ELASTICSEARCH_PASS) !== null && _b !== void 0 ? _b : 'not_provided'
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async push(events) {
|
|
42
|
+
if (events.length)
|
|
43
|
+
await this.elasticClient.bulk({ body: events.reduce((acc, event) => { var _a; acc.push({ index: { _index: (_a = process.env.ELASTICSEARCH_USEREVENTS_INDEX) !== null && _a !== void 0 ? _a : 'interaction-analytics' } }, event); return acc; }, []) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.ElasticAnalyticsClient = ElasticAnalyticsClient;
|
|
47
|
+
function analyticsPlugin(fastify, opts, done) {
|
|
48
|
+
var _a;
|
|
49
|
+
const environment = process.env.NODE_ENV;
|
|
50
|
+
if ((0, txstate_utils_1.isBlank)(environment))
|
|
51
|
+
throw new Error('Must set NODE_ENV when reporting analytics.');
|
|
52
|
+
const eventQueue = [];
|
|
53
|
+
const analyticsClient = (_a = opts.analyticsClient) !== null && _a !== void 0 ? _a : ((0, txstate_utils_1.isBlank)(process.env.ELASTICSEARCH_URL)
|
|
54
|
+
? environment === 'development'
|
|
55
|
+
? new AnalyticsClient()
|
|
56
|
+
: new LoggingAnalyticsClient(fastify.log)
|
|
57
|
+
: new ElasticAnalyticsClient());
|
|
58
|
+
const UACache = new txstate_utils_1.Cache(async (ua) => {
|
|
59
|
+
const parser = new ua_parser_js_1.UAParser(ua);
|
|
60
|
+
return parser.getResult();
|
|
61
|
+
}, { freshseconds: 86400, staleseconds: 864000 });
|
|
62
|
+
async function flushQueue() {
|
|
63
|
+
const eventQueueSlice = [...eventQueue];
|
|
64
|
+
try {
|
|
65
|
+
eventQueue.length = 0;
|
|
66
|
+
const eventsToStore = [];
|
|
67
|
+
for (const queueItem of eventQueueSlice) {
|
|
68
|
+
const uaInfo = await UACache.get(queueItem.ua);
|
|
69
|
+
eventsToStore.push({
|
|
70
|
+
...queueItem.event,
|
|
71
|
+
'@timestamp': queueItem.time,
|
|
72
|
+
appName: opts.appName,
|
|
73
|
+
environment,
|
|
74
|
+
...(0, txstate_utils_1.pick)(uaInfo, 'browser', 'device', 'os'),
|
|
75
|
+
user: {
|
|
76
|
+
remoteip: queueItem.remoteIp,
|
|
77
|
+
ga: queueItem.gaCookie,
|
|
78
|
+
...queueItem.auth
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (eventsToStore.length)
|
|
83
|
+
await analyticsClient.push(eventsToStore);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
eventQueue.push(...eventQueueSlice);
|
|
87
|
+
console.error(e);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
setTimeout(() => { void flushQueue(); }, 5000);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
setTimeout(() => { void flushQueue(); }, 5000);
|
|
94
|
+
function queueEvents(auth, headers, remoteIp, events) {
|
|
95
|
+
var _a, _b, _c;
|
|
96
|
+
for (const event of events) {
|
|
97
|
+
eventQueue.push({
|
|
98
|
+
event,
|
|
99
|
+
remoteIp,
|
|
100
|
+
ua: (_a = headers['user-agent']) !== null && _a !== void 0 ? _a : '',
|
|
101
|
+
time: new Date().toISOString(),
|
|
102
|
+
gaCookie: (_c = (_b = headers.cookie) === null || _b === void 0 ? void 0 : _b.replace(/^.*?(?:_ga=([^;]+))?.*$/, '$1')) !== null && _c !== void 0 ? _c : '',
|
|
103
|
+
auth
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
fastify.post('/analytics', { schema: { body: { type: 'array', items: fastify_shared_1.interactionEvent }, response: { 202: { type: 'string', enum: ['OK'] } } } }, async (req, res) => {
|
|
108
|
+
const { auth } = req;
|
|
109
|
+
queueEvents(auth !== null && auth !== void 0 ? auth : { username: 'unauthenticated' }, req.headers, req.ip, req.body);
|
|
110
|
+
res.statusCode = 202;
|
|
111
|
+
return 'OK';
|
|
112
|
+
});
|
|
113
|
+
done();
|
|
114
|
+
}
|
|
115
|
+
exports.analyticsPlugin = analyticsPlugin;
|
package/lib/error.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class HttpError extends Error {
|
|
2
|
+
statusCode: number;
|
|
3
|
+
constructor(statusCode: number, message?: string);
|
|
4
|
+
}
|
|
5
|
+
type ValidationErrors = Record<string, string[]>;
|
|
6
|
+
export declare class FailedValidationError extends HttpError {
|
|
7
|
+
errors: ValidationErrors;
|
|
8
|
+
constructor(errors: ValidationErrors);
|
|
9
|
+
}
|
|
10
|
+
export {};
|
package/lib/error.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FailedValidationError = exports.HttpError = void 0;
|
|
4
|
+
const http_status_codes_1 = require("http-status-codes");
|
|
5
|
+
class HttpError extends Error {
|
|
6
|
+
constructor(statusCode, message) {
|
|
7
|
+
if (!message) {
|
|
8
|
+
if (statusCode === 401)
|
|
9
|
+
message = 'Authentication is required.';
|
|
10
|
+
else if (statusCode === 403)
|
|
11
|
+
message = 'You are not authorized for that.';
|
|
12
|
+
else
|
|
13
|
+
message = (0, http_status_codes_1.getReasonPhrase)(statusCode);
|
|
14
|
+
}
|
|
15
|
+
super(message);
|
|
16
|
+
this.statusCode = statusCode;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
exports.HttpError = HttpError;
|
|
20
|
+
class FailedValidationError extends HttpError {
|
|
21
|
+
constructor(errors) {
|
|
22
|
+
super(422, 'Validation failure.');
|
|
23
|
+
this.errors = errors;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.FailedValidationError = FailedValidationError;
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { type FastifyDynamicSwaggerOptions } from '@fastify/swagger';
|
|
4
|
+
import { type FastifySwaggerUiOptions } from '@fastify/swagger-ui';
|
|
5
|
+
import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
|
|
6
|
+
import { type FastifyInstance, type FastifyRequest, type FastifyReply, type FastifyServerOptions, type FastifyLoggerOptions, type FastifyBaseLogger, type RawServerDefault } from 'fastify';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import type http2 from 'node:http2';
|
|
9
|
+
type ErrorHandler = (error: Error, req: FastifyRequest, res: FastifyReply) => Promise<void>;
|
|
10
|
+
export interface FastifyTxStateAuthInfo {
|
|
11
|
+
/**
|
|
12
|
+
* The primary identifier for the user that is making the request, after processing
|
|
13
|
+
* their session token / JWT.
|
|
14
|
+
*/
|
|
15
|
+
username: string;
|
|
16
|
+
/**
|
|
17
|
+
* This should be an identifier for the particular session, so that the same user
|
|
18
|
+
* on different devices/browsers/tabs can be distinguished from one another.
|
|
19
|
+
*
|
|
20
|
+
* It should NOT be usable as a cookie or bearer token, as it will appear in logs. If you
|
|
21
|
+
* use JSON Web Tokens, an easy thing is to combine the username with the `iat` issued
|
|
22
|
+
* date to create something unique but not useful to attackers.
|
|
23
|
+
*
|
|
24
|
+
* For lookup tokens, you can do the same `${username}-${createdAt}` after looking up
|
|
25
|
+
* the session in your database.
|
|
26
|
+
*
|
|
27
|
+
* If all else fails, you can sha256 the session token with a salt.
|
|
28
|
+
*/
|
|
29
|
+
sessionId: string;
|
|
30
|
+
/**
|
|
31
|
+
* Some authentication systems allow administrators to impersonate regular users, so that
|
|
32
|
+
* they can see what that user sees and troubleshoot issues. We still want to log the administrator
|
|
33
|
+
* with any actions they take while impersonating someone, for auditing purposes, so you should
|
|
34
|
+
* fill this field when applicable.
|
|
35
|
+
*
|
|
36
|
+
* This will also be available at `req.auth.impersonatedBy`, so it is possible for your API
|
|
37
|
+
* to implement complicated authorization rules based on whether a user is being impersonated.
|
|
38
|
+
* It sort of defeats the purpose of impersonation, but used sparingly it could prevent administrators
|
|
39
|
+
* from making mistakes.
|
|
40
|
+
*/
|
|
41
|
+
impersonatedBy?: string;
|
|
42
|
+
/**
|
|
43
|
+
* If your API may be accessed by a different client application, such that the user is actually logged
|
|
44
|
+
* into that application instead of yours, but you accept that application's session tokens, filling
|
|
45
|
+
* this field can help log requests that are authenticated with the other application's token.
|
|
46
|
+
*/
|
|
47
|
+
clientId?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
|
|
50
|
+
https?: http2.SecureServerOptions;
|
|
51
|
+
validOrigins?: string[];
|
|
52
|
+
validOriginHosts?: string[];
|
|
53
|
+
validOriginSuffixes?: string[];
|
|
54
|
+
skipOriginCheck?: boolean;
|
|
55
|
+
checkOrigin?: (req: FastifyRequest) => boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Run an asynchronous function to check the health of the service.
|
|
58
|
+
*
|
|
59
|
+
* Return a non-empty error message to trigger unhealthy status.
|
|
60
|
+
*
|
|
61
|
+
* Setting a health message with setUnhealthy will override this and prevent it from being executed.
|
|
62
|
+
*/
|
|
63
|
+
checkHealth?: () => Promise<string | {
|
|
64
|
+
status?: number;
|
|
65
|
+
message?: string;
|
|
66
|
+
} | undefined>;
|
|
67
|
+
/**
|
|
68
|
+
* Run an async function to get authentication information out of the request
|
|
69
|
+
* object. Should return an object with at least a username and sessionid (see FastifyTxStateAuthInfo
|
|
70
|
+
* for further detail).
|
|
71
|
+
*
|
|
72
|
+
* The return object will be added to the request object as `req.auth` for later
|
|
73
|
+
* use in your route handlers. It will also be added to the logs in production.
|
|
74
|
+
*
|
|
75
|
+
* IMPORTANT: It is not advisable to return excessive amounts of data here, nor anything
|
|
76
|
+
* particularly sensitive, since it will all be included in every log entry.
|
|
77
|
+
*
|
|
78
|
+
* If this function throws, the client will receive a 401 response.
|
|
79
|
+
*/
|
|
80
|
+
authenticate?: <T extends FastifyTxStateAuthInfo>(req: FastifyRequest) => Promise<T | undefined>;
|
|
81
|
+
}
|
|
82
|
+
declare module 'fastify' {
|
|
83
|
+
interface FastifyRequest {
|
|
84
|
+
auth?: FastifyTxStateAuthInfo;
|
|
85
|
+
}
|
|
86
|
+
interface FastifyReply {
|
|
87
|
+
extraLogInfo: any;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export declare const devLogger: {
|
|
91
|
+
level: string;
|
|
92
|
+
info: (msg: any) => void;
|
|
93
|
+
error: {
|
|
94
|
+
(...data: any[]): void;
|
|
95
|
+
(message?: any, ...optionalParams: any[]): void;
|
|
96
|
+
};
|
|
97
|
+
debug: {
|
|
98
|
+
(...data: any[]): void;
|
|
99
|
+
(message?: any, ...optionalParams: any[]): void;
|
|
100
|
+
};
|
|
101
|
+
fatal: {
|
|
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 default class Server {
|
|
118
|
+
protected config: FastifyTxStateOptions & {
|
|
119
|
+
http2?: true;
|
|
120
|
+
};
|
|
121
|
+
protected https: boolean;
|
|
122
|
+
protected errorHandlers: ErrorHandler[];
|
|
123
|
+
protected healthMessage?: string;
|
|
124
|
+
protected healthCallback?: () => Promise<string | {
|
|
125
|
+
status?: number;
|
|
126
|
+
message?: string;
|
|
127
|
+
} | undefined>;
|
|
128
|
+
protected shuttingDown: boolean;
|
|
129
|
+
protected sigHandler: (signal: any) => void;
|
|
130
|
+
protected validOrigins: Record<string, boolean>;
|
|
131
|
+
protected validOriginHosts: Record<string, boolean>;
|
|
132
|
+
protected validOriginSuffixes: Set<string>;
|
|
133
|
+
app: FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, FastifyBaseLogger, JsonSchemaToTsProvider>;
|
|
134
|
+
constructor(config?: FastifyTxStateOptions & {
|
|
135
|
+
http2?: true;
|
|
136
|
+
});
|
|
137
|
+
start(port?: number): Promise<void>;
|
|
138
|
+
addErrorHandler(handler: ErrorHandler): void;
|
|
139
|
+
setUnhealthy(message: string): void;
|
|
140
|
+
setHealthy(): void;
|
|
141
|
+
setValidOrigins(origins: string[]): void;
|
|
142
|
+
setValidOriginHosts(hosts: string[]): void;
|
|
143
|
+
setValidOriginSuffixes(suffixes: string[]): void;
|
|
144
|
+
swagger(opts?: {
|
|
145
|
+
path?: string;
|
|
146
|
+
openapi?: FastifyDynamicSwaggerOptions['openapi'];
|
|
147
|
+
ui?: FastifySwaggerUiOptions;
|
|
148
|
+
}): Promise<void>;
|
|
149
|
+
close(softSeconds?: number): Promise<void>;
|
|
150
|
+
}
|
|
151
|
+
export * from './analytics';
|
|
152
|
+
export * from './error';
|
|
153
|
+
export * from './unified-auth';
|
package/lib/index.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
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
|
+
};
|
|
2
16
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
17
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
18
|
};
|
|
5
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.
|
|
20
|
+
exports.prodLogger = exports.devLogger = void 0;
|
|
21
|
+
const swagger_1 = __importDefault(require("@fastify/swagger"));
|
|
22
|
+
const swagger_ui_1 = __importDefault(require("@fastify/swagger-ui"));
|
|
23
|
+
const ajv_errors_1 = __importDefault(require("ajv-errors"));
|
|
24
|
+
const ajv_formats_1 = __importDefault(require("ajv-formats"));
|
|
7
25
|
const fastify_1 = require("fastify");
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
26
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
27
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
28
|
+
const txstate_utils_1 = require("txstate-utils");
|
|
29
|
+
const error_1 = require("./error");
|
|
30
|
+
const unified_auth_1 = require("./unified-auth");
|
|
11
31
|
exports.devLogger = {
|
|
12
32
|
level: 'info',
|
|
13
33
|
info: (msg) => { console.info(msg.req ? `${msg.req.method} ${msg.req.url}` : msg.res ? `${msg.res.statusCode} - ${msg.responseTime}` : msg); },
|
|
@@ -25,25 +45,27 @@ exports.prodLogger = {
|
|
|
25
45
|
req(req) {
|
|
26
46
|
return {
|
|
27
47
|
method: req.method,
|
|
28
|
-
url: req.url.replace(/token=[\w.]
|
|
48
|
+
url: req.url.replace(/(token|unifiedJwt)=[\w.]+/i, '$1=redacted'),
|
|
29
49
|
remoteAddress: req.ip,
|
|
30
50
|
traceparent: req.headers.traceparent
|
|
31
51
|
};
|
|
32
52
|
},
|
|
33
53
|
res(res) {
|
|
34
|
-
var _a, _b;
|
|
54
|
+
var _a, _b, _c, _d;
|
|
35
55
|
return {
|
|
36
56
|
statusCode: res.statusCode,
|
|
37
|
-
url: (_a = res.request) === null || _a === void 0 ? void 0 : _a.url.replace(/token=[\w.]
|
|
38
|
-
length: (_b = res.getHeader) === null || _b === void 0 ? void 0 : _b.call(res, 'content-length'),
|
|
39
|
-
...res.extraLogInfo
|
|
57
|
+
url: (_a = res.request) === null || _a === void 0 ? void 0 : _a.url.replace(/(token|unifiedJwt)=[\w.]+/i, '$1=redacted'),
|
|
58
|
+
length: Number((0, txstate_utils_1.toArray)((_b = res.getHeader) === null || _b === void 0 ? void 0 : _b.call(res, 'content-length'))[0]),
|
|
59
|
+
...res.extraLogInfo,
|
|
60
|
+
auth: (_d = (_c = res.request) === null || _c === void 0 ? void 0 : _c.auth) !== null && _d !== void 0 ? _d : res.extraLogInfo.auth
|
|
40
61
|
};
|
|
41
62
|
}
|
|
42
63
|
}
|
|
43
64
|
};
|
|
44
65
|
class Server {
|
|
45
66
|
constructor(config = {}) {
|
|
46
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
67
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
68
|
+
this.config = config;
|
|
47
69
|
this.https = false;
|
|
48
70
|
this.errorHandlers = [];
|
|
49
71
|
this.shuttingDown = false;
|
|
@@ -51,8 +73,8 @@ class Server {
|
|
|
51
73
|
this.validOriginHosts = {};
|
|
52
74
|
this.validOriginSuffixes = new Set();
|
|
53
75
|
try {
|
|
54
|
-
const key =
|
|
55
|
-
const cert =
|
|
76
|
+
const key = node_fs_1.default.readFileSync('/securekeys/private.key');
|
|
77
|
+
const cert = node_fs_1.default.readFileSync('/securekeys/cert.pem');
|
|
56
78
|
config.https = {
|
|
57
79
|
...config.https,
|
|
58
80
|
allowHTTP1: true,
|
|
@@ -78,14 +100,47 @@ class Server {
|
|
|
78
100
|
else
|
|
79
101
|
config.trustProxy = process.env.TRUST_PROXY;
|
|
80
102
|
}
|
|
103
|
+
config.ajv = { ...config.ajv, plugins: [...((_b = (_a = config.ajv) === null || _a === void 0 ? void 0 : _a.plugins) !== null && _b !== void 0 ? _b : []), ajv_errors_1.default, [ajv_formats_1.default, { mode: 'fast' }]], customOptions: { ...(_c = config.ajv) === null || _c === void 0 ? void 0 : _c.customOptions, allErrors: true, strictSchema: false } };
|
|
81
104
|
this.healthCallback = config.checkHealth;
|
|
82
105
|
this.app = (0, fastify_1.fastify)(config);
|
|
106
|
+
this.app.addHook('onRoute', route => {
|
|
107
|
+
var _a, _b, _c;
|
|
108
|
+
if (!((_a = route.schema) === null || _a === void 0 ? void 0 : _a.body))
|
|
109
|
+
return;
|
|
110
|
+
const missingResponse = ((_b = route.schema) === null || _b === void 0 ? void 0 : _b.response) == null;
|
|
111
|
+
const newSchema = (0, txstate_utils_1.set)((_c = route.schema) !== null && _c !== void 0 ? _c : {}, 'response.422', {
|
|
112
|
+
description: 'Validation failure.',
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
errors: {
|
|
116
|
+
type: 'array',
|
|
117
|
+
items: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
type: { type: 'string', enum: ['error', 'warning', 'success', 'system'], example: 'error' },
|
|
121
|
+
message: { type: 'string', example: 'must be an integer' },
|
|
122
|
+
path: { type: 'string', example: 'cart.item.0.quantity', description: 'Dot-separated path to the field in the request body that caused the validation error.' }
|
|
123
|
+
},
|
|
124
|
+
required: ['type', 'message'],
|
|
125
|
+
additionalProperties: false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
if (missingResponse) {
|
|
131
|
+
newSchema.response['200'] = {
|
|
132
|
+
description: 'Success. Return type has not been specified.',
|
|
133
|
+
type: 'null'
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
route.schema = newSchema;
|
|
137
|
+
});
|
|
83
138
|
if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
|
|
84
|
-
this.setValidOrigins([...((
|
|
85
|
-
this.setValidOriginHosts([...((
|
|
86
|
-
this.setValidOriginSuffixes([...((
|
|
139
|
+
this.setValidOrigins([...((_d = config.validOrigins) !== null && _d !== void 0 ? _d : []), ...((_f = (_e = process.env.VALID_ORIGINS) === null || _e === void 0 ? void 0 : _e.split(',')) !== null && _f !== void 0 ? _f : [])]);
|
|
140
|
+
this.setValidOriginHosts([...((_g = config.validOriginHosts) !== null && _g !== void 0 ? _g : []), ...((_j = (_h = process.env.VALID_ORIGIN_HOSTS) === null || _h === void 0 ? void 0 : _h.split(',')) !== null && _j !== void 0 ? _j : [])]);
|
|
141
|
+
this.setValidOriginSuffixes([...((_k = config.validOriginSuffixes) !== null && _k !== void 0 ? _k : []), ...((_m = (_l = process.env.VALID_ORIGIN_SUFFIXES) === null || _l === void 0 ? void 0 : _l.split(',')) !== null && _m !== void 0 ? _m : [])]);
|
|
87
142
|
this.app.addHook('preHandler', async (req, res) => {
|
|
88
|
-
var _a;
|
|
143
|
+
var _a, _b;
|
|
89
144
|
res.extraLogInfo = {};
|
|
90
145
|
if (!req.headers.origin)
|
|
91
146
|
return;
|
|
@@ -119,6 +174,13 @@ class Server {
|
|
|
119
174
|
if (req.headers['access-control-request-headers'])
|
|
120
175
|
void res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
|
|
121
176
|
}
|
|
177
|
+
try {
|
|
178
|
+
req.auth = await ((_b = config.authenticate) === null || _b === void 0 ? void 0 : _b.call(config, req));
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
await res.status(401).send('Failed to authenticate.');
|
|
182
|
+
return res;
|
|
183
|
+
}
|
|
122
184
|
});
|
|
123
185
|
this.app.options('*', async (req, res) => {
|
|
124
186
|
await res.send();
|
|
@@ -138,20 +200,24 @@ class Server {
|
|
|
138
200
|
});
|
|
139
201
|
this.app.setNotFoundHandler((req, res) => { void res.status(404).send('Not Found.'); });
|
|
140
202
|
this.app.setErrorHandler(async (err, req, res) => {
|
|
203
|
+
var _a, _b;
|
|
141
204
|
req.log.warn(err);
|
|
142
205
|
for (const errorHandler of this.errorHandlers) {
|
|
143
206
|
if (!res.sent)
|
|
144
207
|
await errorHandler(err, req, res);
|
|
145
208
|
}
|
|
146
209
|
if (!res.sent) {
|
|
147
|
-
if (err instanceof FailedValidationError) {
|
|
210
|
+
if (err instanceof error_1.FailedValidationError) {
|
|
148
211
|
await res.status(err.statusCode).send(err.errors);
|
|
149
212
|
}
|
|
150
|
-
else if (err instanceof HttpError) {
|
|
213
|
+
else if (err instanceof error_1.HttpError) {
|
|
151
214
|
await res.status(err.statusCode).send(err.message);
|
|
152
215
|
}
|
|
216
|
+
else if (err.code === 'FST_ERR_VALIDATION') {
|
|
217
|
+
await res.status(422).send({ errors: (_b = (_a = err.validation) === null || _a === void 0 ? void 0 : _a.map(err => ({ message: err.message, path: err.instancePath.substring(1).replace(/\//g, '.'), type: 'error' }))) !== null && _b !== void 0 ? _b : [] });
|
|
218
|
+
}
|
|
153
219
|
else if (err.statusCode) {
|
|
154
|
-
await res.status(err.statusCode).send(new HttpError(err.statusCode).message);
|
|
220
|
+
await res.status(err.statusCode).send(new error_1.HttpError(err.statusCode).message);
|
|
155
221
|
}
|
|
156
222
|
else {
|
|
157
223
|
await res.status(500).send('Internal Server Error.');
|
|
@@ -189,14 +255,16 @@ class Server {
|
|
|
189
255
|
process.on('SIGINT', this.sigHandler);
|
|
190
256
|
}
|
|
191
257
|
async start(port) {
|
|
192
|
-
var _a;
|
|
258
|
+
var _a, _b, _c;
|
|
193
259
|
const customPort = port !== null && port !== void 0 ? port : parseInt((_a = process.env.PORT) !== null && _a !== void 0 ? _a : '0');
|
|
260
|
+
await this.app.ready();
|
|
261
|
+
(_c = (_b = this.app).swagger) === null || _c === void 0 ? void 0 : _c.call(_b);
|
|
194
262
|
if (customPort) {
|
|
195
263
|
await this.app.listen({ port: customPort, host: '0.0.0.0' });
|
|
196
264
|
}
|
|
197
265
|
else if (this.https) {
|
|
198
266
|
// redirect 80 to 443
|
|
199
|
-
|
|
267
|
+
node_http_1.default.createServer((req, res) => {
|
|
200
268
|
var _a, _b, _c, _d;
|
|
201
269
|
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 : '') });
|
|
202
270
|
res.end();
|
|
@@ -227,6 +295,25 @@ class Server {
|
|
|
227
295
|
for (const s of suffixes)
|
|
228
296
|
this.validOriginSuffixes.add(s);
|
|
229
297
|
}
|
|
298
|
+
async swagger(opts) {
|
|
299
|
+
var _a, _b, _c, _d;
|
|
300
|
+
let openapi = (_a = opts === null || opts === void 0 ? void 0 : opts.openapi) !== null && _a !== void 0 ? _a : {};
|
|
301
|
+
if (this.config.authenticate === unified_auth_1.unifiedAuthenticate) {
|
|
302
|
+
openapi = (0, txstate_utils_1.set)(openapi, 'components.securitySchemes', {
|
|
303
|
+
unifiedAuth: {
|
|
304
|
+
type: 'http',
|
|
305
|
+
scheme: 'bearer',
|
|
306
|
+
bearerFormat: 'JWT',
|
|
307
|
+
description: `Enter a token obtained from the TxState Unified Authentication service. An easy way to do
|
|
308
|
+
this is log into this application and use dev tools to pull your token from the Authorization header.`
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
// Apply the security globally to all operations
|
|
312
|
+
openapi.security = [{ unifiedAuth: [] }];
|
|
313
|
+
}
|
|
314
|
+
await this.app.register(swagger_1.default, { openapi });
|
|
315
|
+
await this.app.register(swagger_ui_1.default, { ...opts === null || opts === void 0 ? void 0 : opts.ui, routePrefix: (_d = (_b = opts === null || opts === void 0 ? void 0 : opts.path) !== null && _b !== void 0 ? _b : (_c = opts === null || opts === void 0 ? void 0 : opts.ui) === null || _c === void 0 ? void 0 : _c.routePrefix) !== null && _d !== void 0 ? _d : '/docs' });
|
|
316
|
+
}
|
|
230
317
|
async close(softSeconds) {
|
|
231
318
|
var _a;
|
|
232
319
|
if (typeof softSeconds === 'undefined')
|
|
@@ -235,31 +322,12 @@ class Server {
|
|
|
235
322
|
process.removeListener('SIGINT', this.sigHandler);
|
|
236
323
|
if (softSeconds) {
|
|
237
324
|
this.shuttingDown = true;
|
|
238
|
-
await
|
|
325
|
+
await (0, txstate_utils_1.sleep)(softSeconds);
|
|
239
326
|
}
|
|
240
327
|
await this.app.close();
|
|
241
328
|
}
|
|
242
329
|
}
|
|
243
330
|
exports.default = Server;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (statusCode === 401)
|
|
248
|
-
message = 'Authentication is required.';
|
|
249
|
-
else if (statusCode === 403)
|
|
250
|
-
message = 'You are not authorized for that.';
|
|
251
|
-
else
|
|
252
|
-
message = (0, http_status_codes_1.getReasonPhrase)(statusCode);
|
|
253
|
-
}
|
|
254
|
-
super(message);
|
|
255
|
-
this.statusCode = statusCode;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
exports.HttpError = HttpError;
|
|
259
|
-
class FailedValidationError extends HttpError {
|
|
260
|
-
constructor(errors) {
|
|
261
|
-
super(422, 'Validation failure.');
|
|
262
|
-
this.errors = errors;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
exports.FailedValidationError = FailedValidationError;
|
|
331
|
+
__exportStar(require("./analytics"), exports);
|
|
332
|
+
__exportStar(require("./error"), exports);
|
|
333
|
+
__exportStar(require("./unified-auth"), exports);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { type KeyObject } from 'crypto';
|
|
4
|
+
import { type FastifyRequest } from 'fastify';
|
|
5
|
+
import { type JWTPayload, type JWTVerifyGetKey, type KeyLike } from 'jose';
|
|
6
|
+
import { Cache } from 'txstate-utils';
|
|
7
|
+
import { type FastifyTxStateAuthInfo } from '.';
|
|
8
|
+
declare class MockContext<AuthType = any> {
|
|
9
|
+
auth?: AuthType;
|
|
10
|
+
constructor(auth: any);
|
|
11
|
+
waitForAuth(): Promise<AuthType | undefined>;
|
|
12
|
+
static init(): void;
|
|
13
|
+
}
|
|
14
|
+
interface IssuerConfig {
|
|
15
|
+
iss: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
publicKey?: string;
|
|
18
|
+
secret?: string;
|
|
19
|
+
validateUrl: URL;
|
|
20
|
+
}
|
|
21
|
+
declare class Context<AuthType extends FastifyTxStateAuthInfo = FastifyTxStateAuthInfo> extends MockContext<AuthType> {
|
|
22
|
+
private readonly authPromise;
|
|
23
|
+
protected static jwtVerifyKey: KeyObject | undefined;
|
|
24
|
+
protected static issuerKeys: Map<string, KeyLike | JWTVerifyGetKey>;
|
|
25
|
+
protected static issuerConfig: Map<string, any>;
|
|
26
|
+
protected static tokenCache: Cache<string, any, {
|
|
27
|
+
req?: FastifyRequest<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown, import("fastify").FastifyBaseLogger, import("fastify/types/type-provider").ResolveFastifyRequestType<import("fastify").FastifyTypeProviderDefault, import("fastify").FastifySchema, import("fastify").RouteGenericInterface>> | undefined;
|
|
28
|
+
ctx: Context;
|
|
29
|
+
}>;
|
|
30
|
+
constructor(req?: FastifyRequest);
|
|
31
|
+
waitForAuth(): Promise<AuthType | undefined>;
|
|
32
|
+
static init(): void;
|
|
33
|
+
/**
|
|
34
|
+
* If implemented, this method will be called on startup, once per configured issuer. It receives
|
|
35
|
+
* the issuer configuration from the JWT_TRUSTED_ISSUERS environment variable and allows you to manipulate
|
|
36
|
+
* the configuration before storing it.
|
|
37
|
+
*
|
|
38
|
+
* Once stored, whatever you create may be used in your custom validateToken method. For example,
|
|
39
|
+
* you might want to create an in-memory URL object with an issuer's URL so that it can be manipulated
|
|
40
|
+
* easily to send validation checks to the issuer.
|
|
41
|
+
*/
|
|
42
|
+
static processIssuerConfig: undefined | ((config: any) => any);
|
|
43
|
+
/**
|
|
44
|
+
* If implemented, this method is called after a token's signature is checked and passes. You would
|
|
45
|
+
* typically implement this method to check whether the user has manually signed out, or the token has
|
|
46
|
+
* been otherwise deauthorized before its expiration date.
|
|
47
|
+
*
|
|
48
|
+
* If the token is not valid, this method should throw an error with an appropriate message.
|
|
49
|
+
*/
|
|
50
|
+
static validateToken: undefined | ((token: string, issuerConfig: any, claims: JWTPayload) => void | Promise<void>);
|
|
51
|
+
tokenFromReq(req?: FastifyRequest): string | undefined;
|
|
52
|
+
authFromReq(req?: FastifyRequest): Promise<AuthType | undefined>;
|
|
53
|
+
authFromPayload(payload: JWTPayload): Promise<AuthType>;
|
|
54
|
+
}
|
|
55
|
+
declare class TxStateUAuthContext extends Context {
|
|
56
|
+
static processIssuerConfig(config: IssuerConfig): IssuerConfig;
|
|
57
|
+
static validateToken(token: string, issuerConfig: IssuerConfig, claims: any): Promise<void>;
|
|
58
|
+
authFromPayload(payload: JWTPayload): Promise<FastifyTxStateAuthInfo>;
|
|
59
|
+
}
|
|
60
|
+
export declare function unifiedAuthenticate(req: FastifyRequest, ContextClass?: typeof TxStateUAuthContext): Promise<FastifyTxStateAuthInfo | undefined>;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var _a;
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.unifiedAuthenticate = void 0;
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const jose_1 = require("jose");
|
|
7
|
+
const txstate_utils_1 = require("txstate-utils");
|
|
8
|
+
function cleanPem(secretOrPem) {
|
|
9
|
+
return secretOrPem === null || secretOrPem === void 0 ? void 0 : secretOrPem.replace(/(-+BEGIN [\w\s]+ KEY-+)\s*(.*?)\s*(-+END [\w\s]+ KEY-+)/, '$1\n$2\n$3');
|
|
10
|
+
}
|
|
11
|
+
class MockContext {
|
|
12
|
+
constructor(auth) {
|
|
13
|
+
this.auth = auth;
|
|
14
|
+
}
|
|
15
|
+
async waitForAuth() {
|
|
16
|
+
return this.auth;
|
|
17
|
+
}
|
|
18
|
+
static init() { }
|
|
19
|
+
}
|
|
20
|
+
class Context extends MockContext {
|
|
21
|
+
constructor(req) {
|
|
22
|
+
super(undefined);
|
|
23
|
+
this.authPromise = this.authFromReq(req);
|
|
24
|
+
}
|
|
25
|
+
async waitForAuth() {
|
|
26
|
+
this.auth = await this.authPromise;
|
|
27
|
+
return this.auth;
|
|
28
|
+
}
|
|
29
|
+
static init() {
|
|
30
|
+
var _b;
|
|
31
|
+
let secret = cleanPem(process.env.JWT_SECRET_VERIFY);
|
|
32
|
+
if (secret != null) {
|
|
33
|
+
_a.jwtVerifyKey = (0, crypto_1.createPublicKey)(secret);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
secret = cleanPem(process.env.JWT_SECRET);
|
|
37
|
+
if (secret != null) {
|
|
38
|
+
try {
|
|
39
|
+
_a.jwtVerifyKey = (0, crypto_1.createPublicKey)(secret);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
console.info('JWT_SECRET was not a private key, treating it as symmetric.');
|
|
43
|
+
_a.jwtVerifyKey = (0, crypto_1.createSecretKey)(Buffer.from(secret, 'ascii'));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (process.env.JWT_TRUSTED_ISSUERS) {
|
|
48
|
+
const issuers = (0, txstate_utils_1.toArray)(JSON.parse(process.env.JWT_TRUSTED_ISSUERS));
|
|
49
|
+
for (const issuer of issuers) {
|
|
50
|
+
this.issuerConfig.set(issuer.iss, (_b = this.processIssuerConfig) === null || _b === void 0 ? void 0 : _b.call(this, (0, txstate_utils_1.omit)(issuer, 'publicKey', 'secret')));
|
|
51
|
+
if (issuer.url)
|
|
52
|
+
this.issuerKeys.set(issuer.iss, (0, jose_1.createRemoteJWKSet)(new URL(issuer.url)));
|
|
53
|
+
else if (issuer.publicKey)
|
|
54
|
+
this.issuerKeys.set(issuer.iss, (0, crypto_1.createPublicKey)(issuer.publicKey));
|
|
55
|
+
else if (issuer.secret)
|
|
56
|
+
this.issuerKeys.set(issuer.iss, (0, crypto_1.createSecretKey)(Buffer.from(issuer.secret, 'ascii')));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
tokenFromReq(req) {
|
|
61
|
+
var _b;
|
|
62
|
+
const m = (_b = req === null || req === void 0 ? void 0 : req.headers.authorization) === null || _b === void 0 ? void 0 : _b.match(/^bearer (.*)$/i);
|
|
63
|
+
return m === null || m === void 0 ? void 0 : m[1];
|
|
64
|
+
}
|
|
65
|
+
async authFromReq(req) {
|
|
66
|
+
const token = this.tokenFromReq(req);
|
|
67
|
+
if (!token)
|
|
68
|
+
return undefined;
|
|
69
|
+
return this.constructor.tokenCache.get(token, { req, ctx: this });
|
|
70
|
+
}
|
|
71
|
+
async authFromPayload(payload) {
|
|
72
|
+
return payload;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
_a = Context;
|
|
76
|
+
Context.issuerKeys = new Map();
|
|
77
|
+
Context.issuerConfig = new Map();
|
|
78
|
+
Context.tokenCache = new txstate_utils_1.Cache(async (token, { req, ctx }) => {
|
|
79
|
+
var _b, _c;
|
|
80
|
+
// `this` is always the Context class, even if we are making instances of a subclass of Context
|
|
81
|
+
// we need to get the instance's constructor instead in case it has overridden one of our
|
|
82
|
+
// static methods/variables
|
|
83
|
+
const ctxStatic = ctx.constructor;
|
|
84
|
+
const logger = (_b = req === null || req === void 0 ? void 0 : req.log) !== null && _b !== void 0 ? _b : console;
|
|
85
|
+
let verifyKey = _a.jwtVerifyKey;
|
|
86
|
+
try {
|
|
87
|
+
const claims = (0, jose_1.decodeJwt)(token);
|
|
88
|
+
if (claims.iss && ctxStatic.issuerKeys.has(claims.iss))
|
|
89
|
+
verifyKey = ctxStatic.issuerKeys.get(claims.iss);
|
|
90
|
+
if (!verifyKey) {
|
|
91
|
+
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.`);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
await ((_c = ctxStatic.validateToken) === null || _c === void 0 ? void 0 : _c.call(ctxStatic, token, ctxStatic.issuerConfig.get(claims.iss), claims));
|
|
95
|
+
const { payload } = await (0, jose_1.jwtVerify)(token, verifyKey);
|
|
96
|
+
return await ctx.authFromPayload(payload);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
// squelch errors about bad tokens, we can already see the 401 in the log
|
|
100
|
+
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED')
|
|
101
|
+
logger.error(e);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}, { freshseconds: 10 });
|
|
105
|
+
class TxStateUAuthContext extends Context {
|
|
106
|
+
static processIssuerConfig(config) {
|
|
107
|
+
var _b;
|
|
108
|
+
if (config.iss === 'unified-auth') {
|
|
109
|
+
config.validateUrl = new URL((_b = config.url) !== null && _b !== void 0 ? _b : '');
|
|
110
|
+
config.validateUrl.pathname = '/validateToken';
|
|
111
|
+
}
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
static async validateToken(token, issuerConfig, claims) {
|
|
115
|
+
var _b;
|
|
116
|
+
if (claims.iss === 'unified-auth') {
|
|
117
|
+
const validateUrl = new URL(issuerConfig.validateUrl);
|
|
118
|
+
validateUrl.searchParams.set('unifiedJwt', token);
|
|
119
|
+
const resp = await fetch(validateUrl);
|
|
120
|
+
const validate = await resp.json();
|
|
121
|
+
if (!validate.valid)
|
|
122
|
+
throw new Error((_b = validate.reason) !== null && _b !== void 0 ? _b : 'Your session has been ended on another device or in another browser tab/window. It\'s also possible your NetID is no longer active.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async authFromPayload(payload) {
|
|
126
|
+
return {
|
|
127
|
+
username: payload.sub,
|
|
128
|
+
sessionId: payload.sub + '-' + payload.iat,
|
|
129
|
+
clientId: payload.client_id,
|
|
130
|
+
impersonatedBy: payload.impersonatedBy
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function unifiedAuthenticate(req, ContextClass = TxStateUAuthContext) {
|
|
135
|
+
const ctx = new ContextClass(req);
|
|
136
|
+
return ctx.waitForAuth();
|
|
137
|
+
}
|
|
138
|
+
exports.unifiedAuthenticate = unifiedAuthenticate;
|
package/lib-esm/index.d.ts
CHANGED
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
|
-
|
|
3
|
-
import type
|
|
2
|
+
/// <reference types="node" />
|
|
3
|
+
import { type FastifyDynamicSwaggerOptions } from '@fastify/swagger';
|
|
4
|
+
import { type FastifySwaggerUiOptions } from '@fastify/swagger-ui';
|
|
5
|
+
import type { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts';
|
|
6
|
+
import { type FastifyInstance, type FastifyRequest, type FastifyReply, type FastifyServerOptions, type FastifyLoggerOptions, type FastifyBaseLogger, type RawServerDefault } from 'fastify';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
import type http2 from 'node:http2';
|
|
4
9
|
type ErrorHandler = (error: Error, req: FastifyRequest, res: FastifyReply) => Promise<void>;
|
|
10
|
+
export interface FastifyTxStateAuthInfo {
|
|
11
|
+
/**
|
|
12
|
+
* The primary identifier for the user that is making the request, after processing
|
|
13
|
+
* their session token / JWT.
|
|
14
|
+
*/
|
|
15
|
+
username: string;
|
|
16
|
+
/**
|
|
17
|
+
* This should be an identifier for the particular session, so that the same user
|
|
18
|
+
* on different devices/browsers/tabs can be distinguished from one another.
|
|
19
|
+
*
|
|
20
|
+
* It should NOT be usable as a cookie or bearer token, as it will appear in logs. If you
|
|
21
|
+
* use JSON Web Tokens, an easy thing is to combine the username with the `iat` issued
|
|
22
|
+
* date to create something unique but not useful to attackers.
|
|
23
|
+
*
|
|
24
|
+
* For lookup tokens, you can do the same `${username}-${createdAt}` after looking up
|
|
25
|
+
* the session in your database.
|
|
26
|
+
*
|
|
27
|
+
* If all else fails, you can sha256 the session token with a salt.
|
|
28
|
+
*/
|
|
29
|
+
sessionId: string;
|
|
30
|
+
/**
|
|
31
|
+
* Some authentication systems allow administrators to impersonate regular users, so that
|
|
32
|
+
* they can see what that user sees and troubleshoot issues. We still want to log the administrator
|
|
33
|
+
* with any actions they take while impersonating someone, for auditing purposes, so you should
|
|
34
|
+
* fill this field when applicable.
|
|
35
|
+
*
|
|
36
|
+
* This will also be available at `req.auth.impersonatedBy`, so it is possible for your API
|
|
37
|
+
* to implement complicated authorization rules based on whether a user is being impersonated.
|
|
38
|
+
* It sort of defeats the purpose of impersonation, but used sparingly it could prevent administrators
|
|
39
|
+
* from making mistakes.
|
|
40
|
+
*/
|
|
41
|
+
impersonatedBy?: string;
|
|
42
|
+
/**
|
|
43
|
+
* If your API may be accessed by a different client application, such that the user is actually logged
|
|
44
|
+
* into that application instead of yours, but you accept that application's session tokens, filling
|
|
45
|
+
* this field can help log requests that are authenticated with the other application's token.
|
|
46
|
+
*/
|
|
47
|
+
clientId?: string;
|
|
48
|
+
}
|
|
5
49
|
export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
|
|
6
50
|
https?: http2.SecureServerOptions;
|
|
7
51
|
validOrigins?: string[];
|
|
@@ -20,8 +64,25 @@ export interface FastifyTxStateOptions extends Partial<FastifyServerOptions> {
|
|
|
20
64
|
status?: number;
|
|
21
65
|
message?: string;
|
|
22
66
|
} | undefined>;
|
|
67
|
+
/**
|
|
68
|
+
* Run an async function to get authentication information out of the request
|
|
69
|
+
* object. Should return an object with at least a username and sessionid (see FastifyTxStateAuthInfo
|
|
70
|
+
* for further detail).
|
|
71
|
+
*
|
|
72
|
+
* The return object will be added to the request object as `req.auth` for later
|
|
73
|
+
* use in your route handlers. It will also be added to the logs in production.
|
|
74
|
+
*
|
|
75
|
+
* IMPORTANT: It is not advisable to return excessive amounts of data here, nor anything
|
|
76
|
+
* particularly sensitive, since it will all be included in every log entry.
|
|
77
|
+
*
|
|
78
|
+
* If this function throws, the client will receive a 401 response.
|
|
79
|
+
*/
|
|
80
|
+
authenticate?: <T extends FastifyTxStateAuthInfo>(req: FastifyRequest) => Promise<T | undefined>;
|
|
23
81
|
}
|
|
24
82
|
declare module 'fastify' {
|
|
83
|
+
interface FastifyRequest {
|
|
84
|
+
auth?: FastifyTxStateAuthInfo;
|
|
85
|
+
}
|
|
25
86
|
interface FastifyReply {
|
|
26
87
|
extraLogInfo: any;
|
|
27
88
|
}
|
|
@@ -54,6 +115,9 @@ export declare const devLogger: {
|
|
|
54
115
|
};
|
|
55
116
|
export declare const prodLogger: FastifyLoggerOptions;
|
|
56
117
|
export default class Server {
|
|
118
|
+
protected config: FastifyTxStateOptions & {
|
|
119
|
+
http2?: true;
|
|
120
|
+
};
|
|
57
121
|
protected https: boolean;
|
|
58
122
|
protected errorHandlers: ErrorHandler[];
|
|
59
123
|
protected healthMessage?: string;
|
|
@@ -66,7 +130,7 @@ export default class Server {
|
|
|
66
130
|
protected validOrigins: Record<string, boolean>;
|
|
67
131
|
protected validOriginHosts: Record<string, boolean>;
|
|
68
132
|
protected validOriginSuffixes: Set<string>;
|
|
69
|
-
app: FastifyInstance
|
|
133
|
+
app: FastifyInstance<RawServerDefault, http.IncomingMessage, http.ServerResponse<http.IncomingMessage>, FastifyBaseLogger, JsonSchemaToTsProvider>;
|
|
70
134
|
constructor(config?: FastifyTxStateOptions & {
|
|
71
135
|
http2?: true;
|
|
72
136
|
});
|
|
@@ -77,15 +141,13 @@ export default class Server {
|
|
|
77
141
|
setValidOrigins(origins: string[]): void;
|
|
78
142
|
setValidOriginHosts(hosts: string[]): void;
|
|
79
143
|
setValidOriginSuffixes(suffixes: string[]): void;
|
|
144
|
+
swagger(opts?: {
|
|
145
|
+
path?: string;
|
|
146
|
+
openapi?: FastifyDynamicSwaggerOptions['openapi'];
|
|
147
|
+
ui?: FastifySwaggerUiOptions;
|
|
148
|
+
}): Promise<void>;
|
|
80
149
|
close(softSeconds?: number): Promise<void>;
|
|
81
150
|
}
|
|
82
|
-
export
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
type ValidationErrors = Record<string, string[]>;
|
|
87
|
-
export declare class FailedValidationError extends HttpError {
|
|
88
|
-
errors: ValidationErrors;
|
|
89
|
-
constructor(errors: ValidationErrors);
|
|
90
|
-
}
|
|
91
|
-
export {};
|
|
151
|
+
export * from './analytics';
|
|
152
|
+
export * from './error';
|
|
153
|
+
export * from './unified-auth';
|
package/lib-esm/index.js
CHANGED
|
@@ -4,4 +4,9 @@ export const devLogger = ftxst.devLogger
|
|
|
4
4
|
export const prodLogger = ftxst.prodLogger
|
|
5
5
|
export const HttpError = ftxst.HttpError
|
|
6
6
|
export const FailedValidationError = ftxst.FailedValidationError
|
|
7
|
+
export const unifiedAuthenticate = ftxst.unifiedAuthenticate
|
|
8
|
+
export const analyticsPlugin = ftxst.analyticsPlugin
|
|
9
|
+
export const AnalyticsClient = ftxst.AnalyticsClient
|
|
10
|
+
export const LoggingAnalyticsClient = ftxst.LoggingAnalyticsClient
|
|
11
|
+
export const ElasticAnalyticsClient = ftxst.ElasticAnalyticsClient
|
|
7
12
|
export default ftxst.default
|
package/package.json
CHANGED
|
@@ -1,31 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-txstate",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "A small wrapper for fastify providing a set of common conventions & utility functions we use.",
|
|
5
5
|
"exports": {
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"require": "./lib/index.js",
|
|
9
|
+
"import": "./lib-esm/index.js"
|
|
10
|
+
}
|
|
9
11
|
},
|
|
10
|
-
"types": "./lib
|
|
12
|
+
"types": "./lib/index.d.ts",
|
|
11
13
|
"scripts": {
|
|
12
14
|
"prepublishOnly": "npm run build",
|
|
13
|
-
"build": "rm -rf lib && tsc
|
|
15
|
+
"build": "rm -rf lib && tsc",
|
|
14
16
|
"test": "./test.sh",
|
|
15
17
|
"mocha": "TS_NODE_PROJECT=test/tsconfig.json mocha -r ts-node/register test/**/*.ts",
|
|
16
|
-
"testserver": "ts-node-
|
|
18
|
+
"testserver": "node -r ts-node/register --no-warnings testserver/index.ts"
|
|
17
19
|
},
|
|
18
20
|
"dependencies": {
|
|
21
|
+
"@elastic/elasticsearch": "^8.12.2",
|
|
22
|
+
"@fastify/swagger": "^8.14.0",
|
|
23
|
+
"@fastify/swagger-ui": "^3.0.0",
|
|
24
|
+
"@fastify/type-provider-json-schema-to-ts": "^3.0.0",
|
|
25
|
+
"@txstate-mws/fastify-shared": "^1.0.4",
|
|
26
|
+
"ajv-errors": "^3.0.0",
|
|
27
|
+
"ajv-formats": "^2.1.1",
|
|
19
28
|
"fastify": "^4.9.2",
|
|
20
|
-
"
|
|
29
|
+
"fastify-plugin": "^4.5.1",
|
|
30
|
+
"http-status-codes": "^2.1.4",
|
|
31
|
+
"jose": "^5.2.3",
|
|
32
|
+
"txstate-utils": "^1.8.15",
|
|
33
|
+
"ua-parser-js": "^1.0.37"
|
|
21
34
|
},
|
|
22
35
|
"devDependencies": {
|
|
23
36
|
"@types/chai": "^4.2.14",
|
|
24
37
|
"@types/mocha": "^10.0.0",
|
|
25
|
-
"@types/node": "^
|
|
26
|
-
"
|
|
38
|
+
"@types/node": "^20.0.0",
|
|
39
|
+
"@types/ua-parser-js": ">=0.7.39",
|
|
40
|
+
"axios": "^1.6.8",
|
|
27
41
|
"chai": "^4.2.0",
|
|
28
|
-
"eslint-config-standard-with-typescript": "^
|
|
42
|
+
"eslint-config-standard-with-typescript": "^43.0.0",
|
|
43
|
+
"json-schema-to-ts": "^3.0.1",
|
|
29
44
|
"mocha": "^10.0.0",
|
|
30
45
|
"ts-node": "^10.2.1",
|
|
31
46
|
"typescript": "^5.0.4"
|