fastify-txstate 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,19 +28,34 @@ server.app.get('/yourpath', async (req, res) => {
28
28
  This will result in a 400 error being returned to the client, with a plain text body: `Please provide an id.`
29
29
 
30
30
  You may skip the message string and a default will be used, e.g. `throw new HttpError(401)` sends a plain text body: `Authentication is required.`
31
- ## FailedValidationError
32
- This class helps an API communicate with its client about errors that occured during a validation or writing operation. An `errors` object should be passed during construction whose keys correspond to the dot-separated paths of the input object that had problems, and each value is an array of error messages related to that path.
31
+ ## ValidationErrors
32
+ This class helps an API communicate with its client about errors that occured during a validation or writing operation. The constructor takes three arguments: a message to be displayed to the user, a dot-separated path to the property of the input object that the message is related to, and a message type, which could be 'error', 'info', 'warning', 'success', or 'system' (system is for errors that the user is not responsible for like a database being offline).
33
33
  ```javascript
34
- const { FailedValidationError } = require('fastify-txstate')
34
+ import { ValidationErrors } from 'fastify-txstate'
35
+ import { hasFatalErrors } from '@txstate-mws/fastify-shared'
35
36
  server.app.post('/saveathing', async (req, res) => {
36
37
  const thing = req.body
37
- const errors = {}
38
- if (!thing.title) errors.title = ['Title is required.']
39
- if (!thing?.address?.zip) errors['address.zip'] = ['Zip code is required.']
40
- if (Object.keys(errors).length) throw new FailedValidationError(errors)
38
+ const messages = []
39
+ if (!thing.title) messages.push({ message: 'Title is required.', path: 'title', type: 'error' })
40
+ if (!thing?.address?.zip) messages.push({ message: 'Zip code is required.', path: 'address.zip', type: 'error' })
41
+ if (hasFatalErrors(messages)) throw new ValidationErrors(messages)
41
42
  /* continue processing request */
42
43
  })
43
44
  ```
45
+ The client will receive HTTP status 422 and a JSON body that looks like this:
46
+ ```json
47
+ {
48
+ "success": false,
49
+ "messages": [
50
+ { "type": "error", "message": "Zip code is required.", "path": "address.zip" }
51
+ ]
52
+ }
53
+ ```
54
+ This format is well supported by our @txstate-mws/svelte-forms library, so it should be easy to pass the errors into your form.
55
+
56
+ ### ValidationError
57
+ `ValidationErrors` is preferred since it will show multiple errors at once, instead of making the user fix errors one at a time and not know how far they are from being done. If you just need to throw a quick single error, `throw new ValidationError('Wrong!', 'answer')` is also available.
58
+
44
59
  ## Custom Error Handling
45
60
  If you would like special treatment for certain errors, `addErrorHandler` provides an easy way:
46
61
  ```javascript
package/lib/error.d.ts CHANGED
@@ -4,6 +4,11 @@ export declare class HttpError extends Error {
4
4
  statusCode: number;
5
5
  constructor(statusCode: number, message?: string);
6
6
  }
7
+ /**
8
+ * @deprecated This response format is less flexible than the one based on the ValidationMessage
9
+ * interface. Use ValidationError or ValidationErrors instead, and adjust the client to expect
10
+ * the new format.
11
+ */
7
12
  export declare class FailedValidationError extends HttpError {
8
13
  errors: Record<string, string[]>;
9
14
  constructor(errors: Record<string, string[]>);
package/lib/error.js CHANGED
@@ -18,6 +18,11 @@ class HttpError extends Error {
18
18
  }
19
19
  }
20
20
  exports.HttpError = HttpError;
21
+ /**
22
+ * @deprecated This response format is less flexible than the one based on the ValidationMessage
23
+ * interface. Use ValidationError or ValidationErrors instead, and adjust the client to expect
24
+ * the new format.
25
+ */
21
26
  class FailedValidationError extends HttpError {
22
27
  errors;
23
28
  constructor(errors) {
package/lib/index.js CHANGED
@@ -153,12 +153,15 @@ class Server {
153
153
  return JSON.stringify(data);
154
154
  };
155
155
  });
156
+ this.app.addHook('onRequest', (req, res, done) => {
157
+ res.extraLogInfo = {};
158
+ done();
159
+ });
156
160
  if (!config.skipOriginCheck && !process.env.SKIP_ORIGIN_CHECK) {
157
161
  this.setValidOrigins([...(config.validOrigins ?? []), ...(process.env.VALID_ORIGINS?.split(',') ?? [])]);
158
162
  this.setValidOriginHosts([...(config.validOriginHosts ?? []), ...(process.env.VALID_ORIGIN_HOSTS?.split(',') ?? [])]);
159
163
  this.setValidOriginSuffixes([...(config.validOriginSuffixes ?? []), ...(process.env.VALID_ORIGIN_SUFFIXES?.split(',') ?? [])]);
160
164
  this.app.addHook('onRequest', async (req, res) => {
161
- res.extraLogInfo = {};
162
165
  if (!req.headers.origin)
163
166
  return;
164
167
  let passed = this.validOrigins[req.headers.origin];
@@ -207,7 +210,7 @@ class Server {
207
210
  DELETE: true
208
211
  };
209
212
  this.app.addHook('onRequest', async (req, res) => {
210
- if (!authenticatedMethods[req.method])
213
+ if (!authenticatedMethods[req.method] || req.routeOptions.url === '/health')
211
214
  return;
212
215
  try {
213
216
  req.auth = await config.authenticate(req);
@@ -349,7 +352,7 @@ class Server {
349
352
  setValidOriginSuffixes(suffixes) {
350
353
  this.validOriginSuffixes.clear();
351
354
  for (const s of suffixes)
352
- this.validOriginSuffixes.add(s);
355
+ this.validOriginSuffixes.add(s.replace(/^\./, ''));
353
356
  }
354
357
  async swagger(opts) {
355
358
  let openapi = opts?.openapi ?? {};
@@ -1,63 +1,4 @@
1
- /// <reference types="node" />
2
- /// <reference types="node" />
3
- import { type KeyObject } from 'crypto';
4
1
  import { type FastifyRequest } from 'fastify';
5
- import { type JWTPayload, type JWTVerifyGetKey, type KeyLike } from 'jose';
6
- import { Cache } from 'txstate-utils';
7
2
  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
- protected static hasInitialized: boolean;
33
- static init(): void;
34
- /**
35
- * If implemented, this method will be called on startup, once per configured issuer. It receives
36
- * the issuer configuration from the JWT_TRUSTED_ISSUERS environment variable and allows you to manipulate
37
- * the configuration before storing it.
38
- *
39
- * Once stored, whatever you create may be used in your custom validateToken method. For example,
40
- * you might want to create an in-memory URL object with an issuer's URL so that it can be manipulated
41
- * easily to send validation checks to the issuer.
42
- */
43
- static processIssuerConfig: undefined | ((config: any) => any);
44
- /**
45
- * If implemented, this method is called after a token's signature is checked and passes. You would
46
- * typically implement this method to check whether the user has manually signed out, or the token has
47
- * been otherwise deauthorized before its expiration date.
48
- *
49
- * If the token is not valid, this method should throw an error with an appropriate message.
50
- */
51
- static validateToken: undefined | ((token: string, issuerConfig: any, claims: JWTPayload) => void | Promise<void>);
52
- tokenFromReq(req?: FastifyRequest): string | undefined;
53
- authFromReq(req?: FastifyRequest): Promise<AuthType | undefined>;
54
- authFromPayload(payload: JWTPayload): Promise<AuthType>;
55
- }
56
- declare class TxStateUAuthContext extends Context {
57
- static processIssuerConfig(config: IssuerConfig): IssuerConfig;
58
- static validateToken(token: string, issuerConfig: IssuerConfig, claims: any): Promise<void>;
59
- authFromPayload(payload: JWTPayload): Promise<FastifyTxStateAuthInfo>;
60
- }
61
- export declare function unifiedAuthenticate(req: FastifyRequest, ContextClass?: typeof TxStateUAuthContext): Promise<FastifyTxStateAuthInfo | undefined>;
62
- export declare function unifiedAuthenticateAll(req: FastifyRequest, ContextClass?: typeof TxStateUAuthContext): Promise<FastifyTxStateAuthInfo | undefined>;
63
- export {};
3
+ export declare function unifiedAuthenticate(req: FastifyRequest): Promise<FastifyTxStateAuthInfo | undefined>;
4
+ export declare function unifiedAuthenticateAll(req: FastifyRequest): Promise<FastifyTxStateAuthInfo>;
@@ -4,161 +4,114 @@ exports.unifiedAuthenticateAll = exports.unifiedAuthenticate = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const jose_1 = require("jose");
6
6
  const txstate_utils_1 = require("txstate-utils");
7
- function cleanPem(secretOrPem) {
8
- return secretOrPem?.replace(/(-+BEGIN [\w\s]+ KEY-+)\s*(.*?)\s*(-+END [\w\s]+ KEY-+)/, '$1\n$2\n$3');
9
- }
10
- class MockContext {
11
- auth;
12
- constructor(auth) {
13
- this.auth = auth;
14
- }
15
- async waitForAuth() {
16
- return this.auth;
7
+ let hasInit = false;
8
+ const issuerKeys = new Map();
9
+ const issuerConfig = new Map();
10
+ const trustedClients = new Set();
11
+ const tokenCache = new txstate_utils_1.Cache(async (token, req) => {
12
+ const claims = (0, jose_1.decodeJwt)(token);
13
+ let verifyKey;
14
+ if (claims.iss && issuerKeys.has(claims.iss))
15
+ verifyKey = issuerKeys.get(claims.iss);
16
+ if (!verifyKey) {
17
+ req.log.warn(`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.`);
18
+ return undefined;
17
19
  }
18
- static init() { }
19
- }
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);
20
+ try {
21
+ const { payload } = await (0, jose_1.jwtVerify)(token, verifyKey);
22
+ if (trustedClients.size && !trustedClients.has(payload.client_id)) {
23
+ req.log.warn(`Received token with untrusted client_id: ${payload.client_id}.`);
48
24
  return undefined;
49
25
  }
50
- }, { freshseconds: 10 });
51
- constructor(req) {
52
- super(undefined);
53
- this.authPromise = this.authFromReq(req);
54
- }
55
- async waitForAuth() {
56
- this.auth = await this.authPromise;
57
- return this.auth;
58
- }
59
- static hasInitialized = false;
60
- static init() {
61
- if (this.hasInitialized)
62
- return;
63
- this.hasInitialized = true;
64
- let secret = cleanPem(process.env.JWT_SECRET_VERIFY);
65
- if (secret != null) {
66
- Context.jwtVerifyKey = (0, crypto_1.createPublicKey)(secret);
67
- }
68
- else {
69
- secret = cleanPem(process.env.JWT_SECRET);
70
- if (secret != null) {
71
- try {
72
- Context.jwtVerifyKey = (0, crypto_1.createPublicKey)(secret);
73
- }
74
- catch (e) {
75
- console.info('JWT_SECRET was not a private key, treating it as symmetric.');
76
- Context.jwtVerifyKey = (0, crypto_1.createSecretKey)(Buffer.from(secret, 'ascii'));
77
- }
78
- }
79
- }
80
- if (process.env.JWT_TRUSTED_ISSUERS) {
81
- const issuers = (0, txstate_utils_1.toArray)(JSON.parse(process.env.JWT_TRUSTED_ISSUERS));
82
- for (const issuer of issuers) {
83
- this.issuerConfig.set(issuer.iss, this.processIssuerConfig?.((0, txstate_utils_1.omit)(issuer, 'publicKey', 'secret')));
84
- if (issuer.url)
85
- this.issuerKeys.set(issuer.iss, (0, jose_1.createRemoteJWKSet)(new URL(issuer.url)));
86
- else if (issuer.publicKey)
87
- this.issuerKeys.set(issuer.iss, (0, crypto_1.createPublicKey)(issuer.publicKey));
88
- else if (issuer.secret)
89
- this.issuerKeys.set(issuer.iss, (0, crypto_1.createSecretKey)(Buffer.from(issuer.secret, 'ascii')));
90
- }
91
- }
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;
111
- tokenFromReq(req) {
112
- const m = req?.headers.authorization?.match(/^bearer (.*)$/i);
113
- return m?.[1];
26
+ return payload;
114
27
  }
115
- async authFromReq(req) {
116
- const token = this.tokenFromReq(req);
117
- if (!token)
118
- return undefined;
119
- return this.constructor.tokenCache.get(token, { req, ctx: this });
28
+ catch (e) {
29
+ // squelch errors about bad tokens, we can already see the 401 in the log
30
+ if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED')
31
+ req.log.error(e);
32
+ return undefined;
120
33
  }
121
- async authFromPayload(payload) {
122
- return payload;
34
+ }, { freshseconds: 3600 });
35
+ const validateCache = new txstate_utils_1.Cache(async (token, payload) => {
36
+ const config = issuerConfig.get(payload.iss);
37
+ if (!config?.validateUrl)
38
+ return;
39
+ // avoid checking for deauth until the token is more than 5 minutes old
40
+ if (new Date(payload.iat * 1000) > new Date(new Date().getTime() - 1000 * 60 * 5))
41
+ return;
42
+ const validateUrl = new URL(config.validateUrl);
43
+ validateUrl.searchParams.set('unifiedJwt', token);
44
+ const resp = await fetch(validateUrl);
45
+ const validate = await resp.json();
46
+ if (!validate.valid)
47
+ 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.');
48
+ });
49
+ const jwkCache = new txstate_utils_1.Cache(async (url) => {
50
+ const { keys } = await (await fetch(url)).json();
51
+ const publicKeyByKid = {};
52
+ for (const jwk of keys) {
53
+ if (jwk.kid)
54
+ publicKeyByKid[jwk.kid] = await (0, jose_1.importJWK)(jwk);
123
55
  }
56
+ return publicKeyByKid;
57
+ });
58
+ function remoteJWKSet(jwkUrl) {
59
+ return async (protectedHeader) => {
60
+ const publicKeyByKid = await jwkCache.get(jwkUrl);
61
+ return publicKeyByKid[protectedHeader.kid];
62
+ };
124
63
  }
125
- class TxStateUAuthContext extends Context {
126
- static processIssuerConfig(config) {
127
- if (config.iss === 'unified-auth') {
128
- config.validateUrl = new URL(config.url ?? '');
129
- config.validateUrl.pathname = '/validateToken';
130
- }
131
- return config;
64
+ function processIssuerConfig(config) {
65
+ if (config.iss === 'unified-auth') {
66
+ config.validateUrl = new URL(config.url ?? '');
67
+ config.validateUrl.pathname = '/validateToken';
132
68
  }
133
- static async validateToken(token, issuerConfig, claims) {
134
- if (claims.iss === 'unified-auth') {
135
- const validateUrl = new URL(issuerConfig.validateUrl);
136
- validateUrl.searchParams.set('unifiedJwt', token);
137
- const resp = await fetch(validateUrl);
138
- const validate = await resp.json();
139
- if (!validate.valid)
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.');
69
+ return config;
70
+ }
71
+ function init() {
72
+ hasInit = true;
73
+ if (process.env.JWT_TRUSTED_ISSUERS) {
74
+ const issuers = (0, txstate_utils_1.toArray)(JSON.parse(process.env.JWT_TRUSTED_ISSUERS));
75
+ for (const issuer of issuers) {
76
+ issuerConfig.set(issuer.iss, processIssuerConfig(issuer));
77
+ if (issuer.iss === 'unified-auth')
78
+ issuerKeys.set(issuer.iss, remoteJWKSet(issuer.url));
79
+ else if (issuer.url)
80
+ issuerKeys.set(issuer.iss, (0, jose_1.createRemoteJWKSet)(new URL(issuer.url)));
81
+ else if (issuer.publicKey)
82
+ issuerKeys.set(issuer.iss, (0, crypto_1.createPublicKey)(issuer.publicKey));
83
+ else if (issuer.secret)
84
+ issuerKeys.set(issuer.iss, (0, crypto_1.createSecretKey)(Buffer.from(issuer.secret, 'ascii')));
141
85
  }
142
86
  }
143
- async authFromPayload(payload) {
144
- return {
145
- username: payload.sub,
146
- sessionId: payload.sub + '-' + payload.iat,
147
- clientId: payload.client_id,
148
- impersonatedBy: payload.impersonatedBy
149
- };
87
+ for (const clientId of (process.env.JWT_TRUSTED_CLIENTIDS?.split(',').filter(txstate_utils_1.isNotBlank).map(clientId => clientId.trim()) ?? [])) {
88
+ trustedClients.add(clientId);
150
89
  }
151
90
  }
152
- async function unifiedAuthenticate(req, ContextClass = TxStateUAuthContext) {
153
- ContextClass.init();
154
- const ctx = new ContextClass(req);
155
- return ctx.waitForAuth();
91
+ function tokenFromReq(req) {
92
+ const m = req?.headers.authorization?.match(/^bearer (.*)$/i);
93
+ return m?.[1];
94
+ }
95
+ async function unifiedAuthenticate(req) {
96
+ if (!hasInit)
97
+ init();
98
+ const token = tokenFromReq(req);
99
+ if (!token)
100
+ return undefined;
101
+ const payload = await tokenCache.get(token, req);
102
+ if (!payload)
103
+ return undefined;
104
+ await validateCache.get(token, payload);
105
+ return {
106
+ username: payload.sub,
107
+ sessionId: payload.sub + '-' + payload.iat,
108
+ clientId: payload.client_id,
109
+ impersonatedBy: payload.act?.sub
110
+ };
156
111
  }
157
112
  exports.unifiedAuthenticate = unifiedAuthenticate;
158
- async function unifiedAuthenticateAll(req, ContextClass = TxStateUAuthContext) {
159
- ContextClass.init();
160
- const ctx = new ContextClass(req);
161
- const auth = await ctx.waitForAuth();
113
+ async function unifiedAuthenticateAll(req) {
114
+ const auth = await unifiedAuthenticate(req);
162
115
  if (!auth?.username.length && !req.routeOptions.url?.startsWith('/docs'))
163
116
  throw new Error('All requests require authentication.');
164
117
  return auth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastify-txstate",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "A small wrapper for fastify providing a set of common conventions & utility functions we use.",
5
5
  "exports": {
6
6
  ".": {