dce-reactkit 3.12.1-beta-cross-server.2 → 3.12.1-beta-cross-server.3

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.
@@ -1,10 +1,5 @@
1
- // Import crypto
2
- import { createHash } from 'crypto';
3
-
4
- // Import jwt
5
- import jwt from 'jwt-simple';
6
-
7
1
  // Import shared helpers
2
+ // eslint-disable-next-line import/no-cycle
8
3
  import { internalGetCrossServerCredentialCollection } from '../server/initServer';
9
4
 
10
5
  // Import shared types
@@ -16,57 +11,163 @@ import CrossServerCredential from '../types/CrossServerCredential';
16
11
  import MINUTE_IN_MS from '../constants/MINUTE_IN_MS';
17
12
 
18
13
  /*------------------------------------------------------------------------*/
19
- /* ------------------------------ Encoding ------------------------------ */
14
+ /* --------------------------- Dynamic Imports -------------------------- */
20
15
  /*------------------------------------------------------------------------*/
21
16
 
22
17
  /**
23
- * Validate a JWT token
18
+ * Get a copy of the oauth lib
24
19
  * @author Gabe Abrams
25
- * @param encodedSecret the encoded secret
26
- * @return the secret
20
+ * @return the oauth lib
27
21
  */
28
- const decodeCredentialSecret = (encodedSecret: string): string => {
29
- // Get the credential encoding salt
30
- const credentialEncodingSalt = process.env.REACTKIT_CRED_ENCODING_SALT;
31
- if (!credentialEncodingSalt) {
22
+ const getOauthLib = async () => {
23
+ // Get the oauth signing library (included with caccl)
24
+ // Ignore because this is assumed to be included with caccl
25
+ try {
26
+ // @ts-ignore
27
+ // eslint-disable-next-line import/no-extraneous-dependencies
28
+ const oauth = await import('oauth-signature');
29
+ return oauth;
30
+ } catch (err) {
32
31
  throw new ErrorWithCode(
33
- 'Cannot decode a cross-server credential secret because the credential encoding salt was not found in env.',
34
- ReactKitErrorCode.CrossServerNoCredentialEncodingSalt,
32
+ 'Could not sign a cross-server request because we could not load the oauth library. Please make sure this app has caccl as one of its dependencies.',
33
+ ReactKitErrorCode.NoOauthLib,
35
34
  );
36
35
  }
36
+ };
37
37
 
38
- // Decode the secret
39
- return jwt.decode(encodedSecret, credentialEncodingSalt);
38
+ /**
39
+ * Get a copy of the crypto lib
40
+ * @author Gabe Abrams
41
+ * @return the crypto lib
42
+ */
43
+ const getCryptoLib = async () => {
44
+ // Get the crypto library (included on the server)
45
+ // Ignore because this is assumed to be included with typescript
46
+ // @ts-ignore
47
+ try {
48
+ const crypto = await import('crypto');
49
+ return crypto;
50
+ } catch (err) {
51
+ throw new ErrorWithCode(
52
+ 'Could not sign a cross-server request because we could not load the crypto library. Please make sure this operation is running on the server.',
53
+ ReactKitErrorCode.NoCryptoLib,
54
+ );
55
+ }
40
56
  };
41
57
 
42
58
  /*------------------------------------------------------------------------*/
43
- /* ------------------------------- Signing ------------------------------ */
59
+ /* ------------------------------- Helpers ------------------------------ */
44
60
  /*------------------------------------------------------------------------*/
45
61
 
46
62
  /**
47
- * Sign using sha 256
63
+ * Generate an oauth signature
48
64
  * @author Gabe Abrams
49
65
  * @param opts object containing all arguments
50
- * @param opts.pack the pack to sign
51
- * @param opts.secret the reactkit secret to sign with
52
- * @return the signed hash
66
+ * @param opts.method the http method
67
+ * @param opts.path the http request path
68
+ * @param opts.params the data in the body to sign
69
+ * @param opts.secret the secret to sign with
70
+ * @return the signature
53
71
  */
54
- const genSignature = (
72
+ const genSignature = async (
55
73
  opts: {
56
- pack: string,
74
+ method?: string,
75
+ path?: string,
76
+ params?: { [key: string]: any },
57
77
  secret: string,
58
78
  },
59
- ): string => {
60
- // Generate signature
61
- return (
62
- createHash('sha256')
63
- .update(JSON.stringify({ pack: opts.pack, secret: opts.secret }))
64
- .digest('base64')
79
+ ): Promise<string> => {
80
+ // Destructure opts
81
+ const {
82
+ method,
83
+ path,
84
+ params,
85
+ secret,
86
+ } = opts;
87
+
88
+ // Get the oauth library
89
+ const oauth = await getOauthLib();
90
+
91
+ // Order the params alphabetically by key
92
+ const keys = Object.keys(params ?? {});
93
+ keys.sort();
94
+ const orderedParams: {
95
+ [key: string]: any,
96
+ } = {};
97
+ keys.forEach((key) => {
98
+ // Skip oauth_signature
99
+ if (key === 'oauth_signature') {
100
+ return;
101
+ }
102
+
103
+ // Add the param
104
+ orderedParams[key] = (params ?? {})[key];
105
+ });
106
+
107
+ // Generate the signature
108
+ return decodeURIComponent(oauth.generate(
109
+ method ?? 'GET',
110
+ path ?? 'no-path',
111
+ orderedParams,
112
+ secret,
113
+ ));
114
+ };
115
+
116
+ /**
117
+ * Decrypt an encrypted string using a secret
118
+ * @author Gabe Abrams
119
+ * @param str the encrypted string
120
+ * @return the decrypted string
121
+ */
122
+ const decrypt = async (
123
+ encryptedPack: string,
124
+ ): Promise<string> => {
125
+ // Decryption process based on:
126
+ // https://medium.com/@tony.infisical/guide-to-nodes-crypto-module-for-encryption-decryption-65c077176980
127
+
128
+ // Get the encryption secret
129
+ const { REACTKIT_CRED_ENCODING_SALT } = process.env;
130
+ if (!REACTKIT_CRED_ENCODING_SALT) {
131
+ throw new ErrorWithCode(
132
+ 'Could not decrypt a string because the encryption salt was not set.',
133
+ ReactKitErrorCode.CrossServerNoCredentialEncodingSalt,
134
+ );
135
+ }
136
+
137
+ // Get the crypto library
138
+ const crypto = await getCryptoLib();
139
+
140
+ // Separate encrypted pack
141
+ const {
142
+ ciphertext,
143
+ iv,
144
+ tag,
145
+ } = JSON.parse(decodeURIComponent(encryptedPack));
146
+
147
+ // Parse the encrypted data
148
+ const decipher = crypto.createDecipheriv(
149
+ 'aes-256-gcm',
150
+ Buffer.from(REACTKIT_CRED_ENCODING_SALT, 'base64'),
151
+ Buffer.from(iv, 'base64'),
65
152
  );
153
+
154
+ // Set the authentication tag
155
+ decipher.setAuthTag(Buffer.from(tag, 'base64'));
156
+
157
+ // Decrypt the string
158
+ let str = decipher.update(ciphertext, 'base64', 'utf8');
159
+ str += decipher.final('utf8');
160
+
161
+ // Return the decrypted string
162
+ return str;
66
163
  };
67
164
 
165
+ /*------------------------------------------------------------------------*/
166
+ /* ------------------------------- Signing ------------------------------ */
167
+ /*------------------------------------------------------------------------*/
168
+
68
169
  /**
69
- * Sign data with a private reactkit key, package it into a signed data pack
170
+ * Sign a request and get the new request params
70
171
  * @author Gabe Abrams
71
172
  * @param opts object containing all arguments
72
173
  * @param opts.method the method to sign
@@ -74,9 +175,9 @@ const genSignature = (
74
175
  * @param opts.params the data in the body to sign
75
176
  * @param opts.key the reactkit key to sign with
76
177
  * @param opts.secret the reactkit secret to sign with
77
- * @return the signed data
178
+ * @return augmented params for the request, including a signature, timestamp, and key
78
179
  */
79
- export const createSignedPack = (
180
+ export const signRequest = async (
80
181
  opts: {
81
182
  method: string,
82
183
  path: string,
@@ -84,111 +185,108 @@ export const createSignedPack = (
84
185
  key: string,
85
186
  secret: string,
86
187
  },
87
- ): string => {
88
- // Create a timestamp
89
- const timestamp = Date.now();
90
-
91
- // Create the pack
92
- const pack = JSON.stringify({
93
- method: opts.method,
94
- path: opts.path,
95
- params: opts.params,
96
- key: opts.key,
97
- timestamp,
98
- });
188
+ ): Promise<{ [key: string]: any }> => {
189
+ // Destructure opts
190
+ const method = opts.method.toUpperCase();
191
+ const {
192
+ path,
193
+ params,
194
+ key,
195
+ secret,
196
+ } = opts;
99
197
 
100
- // Generate signature
101
- const signature = genSignature({
102
- pack,
103
- secret: opts.secret,
198
+ // Augment the params
199
+ const augmentedParams: {
200
+ [key: string]: any,
201
+ } = {
202
+ ...params,
203
+ oauth_consumer_key: key,
204
+ oauth_nonce: Math.random().toString(36),
205
+ oauth_timestamp: Date.now(),
206
+ };
207
+
208
+ // Generate a signature
209
+ const signature = await genSignature({
210
+ method,
211
+ path,
212
+ params,
213
+ secret,
104
214
  });
105
215
 
106
- // Create a signed pack
107
- const signedPack = encodeURIComponent(JSON.stringify({
108
- pack,
109
- signature,
110
- }));
216
+ // Add signature to the augmented params
217
+ augmentedParams.oauth_signature = signature;
111
218
 
112
- // Return the signed pack
113
- return signedPack;
219
+ // Return the augmented params
220
+ return augmentedParams;
114
221
  };
115
222
 
116
223
  /**
117
- * Parse signed pack. Throws an error if invalid
224
+ * Validate a signed request. Throws an error if invalid
118
225
  * @author Gabe Abrams
119
226
  * @param opts object containing all arguments
120
227
  * @param opts.method the method of the data validate
121
228
  * @param opts.path the http request path to validate
122
229
  * @param opts.scope the name of the scope to validate
123
- * @param opts.signedPack the signed data pack to validate
230
+ * @param opts.params the request data to validate
124
231
  * @returns parsed and validated params
125
232
  */
126
- export const parseSignedPack = async (
233
+ export const validateSignedRequest = async (
127
234
  opts: {
128
235
  method: string,
129
236
  path: string,
130
237
  scope: string,
131
- signedPack: string,
238
+ params: { [key: string]: any },
132
239
  },
133
- ): Promise<{ [key: string]: any }> => {
134
- // Extract signature
135
- let pack: string;
136
- let signature: string;
137
- let method: string;
138
- let path: string;
139
- let key: string;
140
- let timestamp: number;
141
- let params: { [k: string]: any };
142
- try {
143
- ({
144
- pack,
145
- signature,
146
- } = JSON.parse(decodeURIComponent(opts.signedPack)));
147
-
148
- // Unpack
149
- ({
150
- method,
151
- path,
152
- params,
153
- key,
154
- timestamp,
155
- } = JSON.parse(pack));
156
- } catch (err) {
157
- throw new ErrorWithCode(
158
- 'Could not validate a cross-server request because the request could not be parsed.',
159
- ReactKitErrorCode.PackUnparseable,
160
- );
161
- }
240
+ ) => {
241
+ /* ---------- Collect Info ---------- */
162
242
 
163
- // Make sure the method and path match
164
- if (method !== opts.method) {
243
+ // Get the signature
244
+ if (!opts.params.oauth_signature) {
165
245
  throw new ErrorWithCode(
166
- 'Could not validate a cross-server request because the method did not match.',
167
- ReactKitErrorCode.PackInvalidMethod,
246
+ 'Could not validate a cross-server request there was no oauth signature.',
247
+ ReactKitErrorCode.CrossServerMissingSignedRequestInfo,
168
248
  );
169
249
  }
170
- if (path !== opts.path) {
250
+ const signature = opts.params.oauth_signature;
251
+
252
+ // Get the timestamp
253
+ if (
254
+ // No timestamp
255
+ !opts.params.oauth_timestamp
256
+ // Invalid timestamp
257
+ || Number.isNaN(Number.parseInt(opts.params.oauth_timestamp, 10))
258
+ ) {
171
259
  throw new ErrorWithCode(
172
- 'Could not validate a cross-server request because the path did not match.',
173
- ReactKitErrorCode.PackInvalidPath,
260
+ 'Could not validate a cross-server request there was no valid oauth timestamp.',
261
+ ReactKitErrorCode.CrossServerMissingSignedRequestInfo,
174
262
  );
175
263
  }
264
+ const timestamp = Number.parseInt(opts.params.oauth_timestamp, 10);
176
265
 
177
- // Make sure the timestamp was recent enough
178
- const elapsedMs = Math.abs(Date.now() - timestamp);
179
- if (elapsedMs < MINUTE_IN_MS) {
266
+ // Get the key
267
+ if (!opts.params.oauth_consumer_key) {
180
268
  throw new ErrorWithCode(
181
- 'Could not validate a cross-server request because the request was too old.',
182
- ReactKitErrorCode.PackInvalidTimestamp,
269
+ 'Could not validate a cross-server request there was no oauth consumer key.',
270
+ ReactKitErrorCode.CrossServerMissingSignedRequestInfo,
183
271
  );
184
272
  }
273
+ const key = opts.params.oauth_consumer_key;
274
+
275
+ // Get the rest of the info
276
+ const {
277
+ method,
278
+ path,
279
+ params,
280
+ } = opts;
281
+
282
+ /* ------- Look Up Credential ------- */
185
283
 
186
284
  // Get the cross-server credential collection
187
285
  const crossServerCredentialCollection = internalGetCrossServerCredentialCollection();
188
286
  if (!crossServerCredentialCollection) {
189
287
  throw new ErrorWithCode(
190
288
  'Could not validate a cross-server request because the cross-server credential collection was not ready in time.',
191
- ReactKitErrorCode.PackInvalidCollection,
289
+ ReactKitErrorCode.SignedRequestInvalidCollection,
192
290
  );
193
291
  }
194
292
 
@@ -197,7 +295,7 @@ export const parseSignedPack = async (
197
295
  if (!crossServerCredential) {
198
296
  throw new ErrorWithCode(
199
297
  'Could not validate a cross-server request because the credential was not found.',
200
- ReactKitErrorCode.PackInvalidCredential,
298
+ ReactKitErrorCode.SignedRequestInvalidCredential,
201
299
  );
202
300
  }
203
301
 
@@ -206,27 +304,37 @@ export const parseSignedPack = async (
206
304
  if (!allowedScopes.includes(opts.scope)) {
207
305
  throw new ErrorWithCode(
208
306
  'Could not validate a cross-server request because the scope was not included.',
209
- ReactKitErrorCode.PackInvalidScope,
307
+ ReactKitErrorCode.SignedRequestInvalidScope,
210
308
  );
211
309
  }
212
310
 
213
311
  // Decode the secret
214
- const secret = decodeCredentialSecret(crossServerCredential.encodedeSecret);
312
+ const secret = await decrypt(crossServerCredential.encodedeSecret);
313
+
314
+ /* -------- Verify Signature -------- */
215
315
 
216
- // Generate signature
217
- const expectedSignature = genSignature({
218
- pack,
316
+ // Generate a new signature to compare
317
+ const expectedSignature = await genSignature({
318
+ method,
319
+ path,
320
+ params,
219
321
  secret,
220
322
  });
221
323
 
222
- // Make sure the signature matches
324
+ // Make sure the signatures match
223
325
  if (signature !== expectedSignature) {
224
326
  throw new ErrorWithCode(
225
327
  'Could not validate a cross-server request because the signature did not match.',
226
- ReactKitErrorCode.PackInvalidSignature,
328
+ ReactKitErrorCode.SignedRequestInvalidSignature,
227
329
  );
228
330
  }
229
331
 
230
- // Return body
231
- return params ?? {};
332
+ // Make sure the timestamp was recent enough
333
+ const elapsedMs = Math.abs(Date.now() - timestamp);
334
+ if (elapsedMs < MINUTE_IN_MS) {
335
+ throw new ErrorWithCode(
336
+ 'Could not validate a cross-server request because the request was too old.',
337
+ ReactKitErrorCode.SignedRequestInvalidTimestamp,
338
+ );
339
+ }
232
340
  };
@@ -17,7 +17,7 @@ import genErrorPage from '../html/genErrorPage';
17
17
  import genInfoPage from '../html/genInfoPage';
18
18
  import parseUserAgent from './parseUserAgent';
19
19
  import getTimeInfoInET from './getTimeInfoInET';
20
- import { parseSignedPack } from './dataSigner';
20
+ import { validateSignedRequest } from './dataSigner';
21
21
 
22
22
  // Import shared types
23
23
  import LogFunction from '../types/LogFunction';
@@ -30,7 +30,6 @@ import LogSourceSpecificInfo from '../types/Log/LogSourceSpecificInfo';
30
30
  import LogBuiltInMetadata from '../types/LogBuiltInMetadata';
31
31
  import LogAction from '../types/LogAction';
32
32
  import LogLevel from '../types/LogLevel';
33
- import ErrorWithCode from '../errors/ErrorWithCode';
34
33
 
35
34
  /**
36
35
  * Generate an express API route handler
@@ -137,7 +136,7 @@ const genRouteHandler = (
137
136
  );
138
137
 
139
138
  // Get body from everywhere it can come from
140
- let requestBody: {
139
+ const requestBody: {
141
140
  [k: string]: any,
142
141
  } = {
143
142
  ...req.body,
@@ -150,27 +149,31 @@ const genRouteHandler = (
150
149
  /*----------------------------------------*/
151
150
 
152
151
  if (crossServerScope) {
153
- // Get the signed pack
154
- const { signedPack } = requestBody;
155
-
156
- // If no pack, throw error
157
- if (!signedPack || typeof signedPack !== 'string') {
158
- throw new ErrorWithCode(
159
- 'Could not process a cross server request because there was no valid signed pack.',
160
- ReactKitErrorCode.CrossServerNoPack,
152
+ try {
153
+ // Validate the request body
154
+ await validateSignedRequest({
155
+ method: req.method ?? 'GET',
156
+ path: req.path,
157
+ scope: crossServerScope,
158
+ params: requestBody,
159
+ });
160
+
161
+ // Valid! Remove oauth values
162
+ Object.keys(requestBody).forEach((key) => {
163
+ if (key.startsWith('oauth_')) {
164
+ delete requestBody[key];
165
+ }
166
+ });
167
+ } catch (err) {
168
+ return handleError(
169
+ res,
170
+ {
171
+ message: `The authenticity of a cross-server request could not be validated because an error occurred: ${(err as any).message ?? 'unknown error'}`,
172
+ code: ((err as any).code ?? ReactKitErrorCode.UnknownCrossServerError),
173
+ status: 401,
174
+ },
161
175
  );
162
176
  }
163
-
164
- // Validate the request
165
- const crossServerParams = await parseSignedPack({
166
- method: req.method ?? 'GET',
167
- path: req.path,
168
- scope: crossServerScope,
169
- signedPack,
170
- });
171
-
172
- // Replace body with params from the pack
173
- requestBody = crossServerParams ?? {};
174
177
  }
175
178
 
176
179
  /*----------------------------------------*/
@@ -586,14 +589,14 @@ const genRouteHandler = (
586
589
  // Main log info
587
590
  const mainLogInfo: LogMainInfo = {
588
591
  id: `${launchInfo ? launchInfo.userId : 'unknown'}-${Date.now()}-${Math.floor(Math.random() * 100000)}-${Math.floor(Math.random() * 100000)}`,
589
- userFirstName: (launchInfo ? launchInfo.userFirstName: 'unknown'),
592
+ userFirstName: (launchInfo ? launchInfo.userFirstName : 'unknown'),
590
593
  userLastName: (launchInfo ? launchInfo.userLastName : 'unknown'),
591
594
  userEmail: (launchInfo ? launchInfo.userEmail : 'unknown'),
592
595
  userId: (launchInfo ? launchInfo.userId : 'unknown'),
593
596
  isLearner: (launchInfo && !!launchInfo.isLearner),
594
597
  isAdmin: (launchInfo && !!launchInfo.isAdmin),
595
598
  isTTM: (launchInfo && !!launchInfo.isTTM),
596
- courseId: (launchInfo ? launchInfo.courseId : 'unknown' ),
599
+ courseId: (launchInfo ? launchInfo.courseId : 'unknown'),
597
600
  courseName: (launchInfo ? launchInfo.contextLabel : 'unknown'),
598
601
  browser,
599
602
  device,