@prairielearn/signed-token 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # `@prairielearn/signed-token`
2
+
3
+ A package for generating signed tokens. Useful for CSRF tokens or generally to round-trip trusted data through an untrusted client.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import {
9
+ generateSignedToken,
10
+ getCheckedSignedTokenData,
11
+ checkSignedToken,
12
+ } from '@prairielearn/signed-token';
13
+
14
+ const token = generateSignedToken({ foo: 'bar' }, 'SECRET_KEY');
15
+
16
+ console.log(getCheckedSignedTokenData(token, 'SECRET_KEY', { maxAge: 60 * 1000 }));
17
+ // { foo: 'bar' }
18
+
19
+ console.log(checkSignedToken(token, { foo: 'bar' }, 'SECRET_KEY', { maxAge: 60 * 1000 }));
20
+ // true
21
+
22
+ console.log(checkSignedToken(token, { foo: 'baz' }, 'SECRET_KEY', { maxAge: 60 * 1000 }));
23
+ // false
24
+ ```
@@ -0,0 +1,7 @@
1
+ interface CheckOptions {
2
+ maxAge?: number;
3
+ }
4
+ export declare function generateSignedToken(data: any, secretKey: string): string;
5
+ export declare function getCheckedSignedTokenData(token: string, secretKey: string, options?: CheckOptions): any;
6
+ export declare function checkSignedToken(token: string, data: any, secretKey: string, options?: CheckOptions): boolean;
7
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
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.checkSignedToken = exports.getCheckedSignedTokenData = exports.generateSignedToken = void 0;
7
+ const base64url_1 = __importDefault(require("base64url"));
8
+ const debug_1 = __importDefault(require("debug"));
9
+ const lodash_1 = __importDefault(require("lodash"));
10
+ const node_crypto_1 = __importDefault(require("node:crypto"));
11
+ const debug = (0, debug_1.default)('prairielearn:csrf');
12
+ const sep = '.';
13
+ function generateSignedToken(data, secretKey) {
14
+ debug(`generateSignedToken(): data = ${JSON.stringify(data)}`);
15
+ debug(`generateSignedToken(): secretKey = ${secretKey}`);
16
+ const dataJSON = JSON.stringify(data);
17
+ const dataString = base64url_1.default.encode(dataJSON);
18
+ const dateString = new Date().getTime().toString(36);
19
+ const checkString = dateString + sep + dataString;
20
+ const signature = node_crypto_1.default.createHmac('sha256', secretKey).update(checkString).digest('hex');
21
+ const encodedSignature = base64url_1.default.encode(signature);
22
+ debug(`generateSignedToken(): ${JSON.stringify({
23
+ dataString,
24
+ dateString,
25
+ checkString,
26
+ encodedSignature,
27
+ })}`);
28
+ const token = encodedSignature + sep + checkString;
29
+ debug(`generateSignedToken(): token = ${token}`);
30
+ return token;
31
+ }
32
+ exports.generateSignedToken = generateSignedToken;
33
+ function getCheckedSignedTokenData(token, secretKey, options = {}) {
34
+ debug(`getCheckedSignedTokenData(): token = ${token}`);
35
+ debug(`getCheckedSignedTokenData(): secretKey = ${secretKey}`);
36
+ debug(`getCheckedSignedTokenData(): options = ${JSON.stringify(options)}`);
37
+ if (!lodash_1.default.isString(token)) {
38
+ debug(`getCheckedSignedTokenData(): FAIL - token is not string`);
39
+ return null;
40
+ }
41
+ // break token apart into the three components
42
+ const match = token.split(sep);
43
+ if (match == null) {
44
+ debug(`getCheckedSignedTokenData(): FAIL - could not split token`);
45
+ return null;
46
+ }
47
+ const tokenSignature = match[0];
48
+ const tokenDateString = match[1];
49
+ const tokenDataString = match[2];
50
+ // check the signature
51
+ const checkString = tokenDateString + sep + tokenDataString;
52
+ const checkSignature = node_crypto_1.default.createHmac('sha256', secretKey).update(checkString).digest('hex');
53
+ const encodedCheckSignature = base64url_1.default.encode(checkSignature);
54
+ if (encodedCheckSignature !== tokenSignature) {
55
+ debug(`getCheckedSignedTokenData(): FAIL - signature mismatch: checkSig=${encodedCheckSignature} != tokenSig=${tokenSignature}`);
56
+ return null;
57
+ }
58
+ // check the age if we have the maxAge parameter
59
+ if (options.maxAge != null) {
60
+ let tokenDate;
61
+ try {
62
+ tokenDate = new Date(parseInt(tokenDateString, 36));
63
+ }
64
+ catch (e) {
65
+ debug(`getCheckedSignedTokenData(): FAIL - could not parse date: ${tokenDateString}`);
66
+ return null;
67
+ }
68
+ const currentTime = Date.now();
69
+ const elapsedTime = currentTime - tokenDate.getTime();
70
+ if (elapsedTime > options.maxAge) {
71
+ debug(`getCheckedSignedTokenData(): FAIL - too old: elapsedTime=${elapsedTime} > maxAge=${options.maxAge}`);
72
+ return null;
73
+ }
74
+ }
75
+ // get the data
76
+ let tokenDataJSON, tokenData;
77
+ try {
78
+ tokenDataJSON = base64url_1.default.decode(tokenDataString);
79
+ }
80
+ catch (e) {
81
+ debug(`getCheckedSignedTokenData(): FAIL - could not base64 decode: ${tokenDateString}`);
82
+ return null;
83
+ }
84
+ try {
85
+ tokenData = JSON.parse(tokenDataJSON);
86
+ }
87
+ catch (e) {
88
+ debug(`getCheckedSignedTokenData(): FAIL - could not parse JSON: ${tokenDataJSON}`);
89
+ return null;
90
+ }
91
+ debug(`getCheckedSignedTokenData(): tokenData = ${tokenData}`);
92
+ return tokenData;
93
+ }
94
+ exports.getCheckedSignedTokenData = getCheckedSignedTokenData;
95
+ function checkSignedToken(token, data, secretKey, options = {}) {
96
+ debug(`checkSignedToken(): token = ${token}`);
97
+ debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);
98
+ debug(`checkSignedToken(): secretKey = ${secretKey}`);
99
+ debug(`checkSignedToken(): options = ${JSON.stringify(options)}`);
100
+ debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);
101
+ const tokenData = getCheckedSignedTokenData(token, secretKey, options);
102
+ debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);
103
+ if (tokenData == null)
104
+ return false;
105
+ if (!lodash_1.default.isEqual(data, tokenData))
106
+ return false;
107
+ return true;
108
+ }
109
+ exports.checkSignedToken = checkSignedToken;
110
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAAA,0DAAkC;AAClC,kDAAgC;AAChC,oDAAuB;AACvB,8DAAiC;AAEjC,MAAM,KAAK,GAAG,IAAA,eAAW,EAAC,mBAAmB,CAAC,CAAC;AAC/C,MAAM,GAAG,GAAG,GAAG,CAAC;AAMhB,SAAgB,mBAAmB,CAAC,IAAS,EAAE,SAAiB;IAC9D,KAAK,CAAC,iCAAiC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/D,KAAK,CAAC,sCAAsC,SAAS,EAAE,CAAC,CAAC;IACzD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IACtC,MAAM,UAAU,GAAG,mBAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC9C,MAAM,UAAU,GAAG,IAAI,IAAI,EAAE,CAAC,OAAO,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACrD,MAAM,WAAW,GAAG,UAAU,GAAG,GAAG,GAAG,UAAU,CAAC;IAClD,MAAM,SAAS,GAAG,qBAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3F,MAAM,gBAAgB,GAAG,mBAAS,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACrD,KAAK,CACH,0BAA0B,IAAI,CAAC,SAAS,CAAC;QACvC,UAAU;QACV,UAAU;QACV,WAAW;QACX,gBAAgB;KACjB,CAAC,EAAE,CACL,CAAC;IACF,MAAM,KAAK,GAAG,gBAAgB,GAAG,GAAG,GAAG,WAAW,CAAC;IACnD,KAAK,CAAC,kCAAkC,KAAK,EAAE,CAAC,CAAC;IACjD,OAAO,KAAK,CAAC;AACf,CAAC;AApBD,kDAoBC;AAED,SAAgB,yBAAyB,CACvC,KAAa,EACb,SAAiB,EACjB,UAAwB,EAAE;IAE1B,KAAK,CAAC,wCAAwC,KAAK,EAAE,CAAC,CAAC;IACvD,KAAK,CAAC,4CAA4C,SAAS,EAAE,CAAC,CAAC;IAC/D,KAAK,CAAC,0CAA0C,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC3E,IAAI,CAAC,gBAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE;QACtB,KAAK,CAAC,yDAAyD,CAAC,CAAC;QACjE,OAAO,IAAI,CAAC;KACb;IAED,8CAA8C;IAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,IAAI,IAAI,EAAE;QACjB,KAAK,CAAC,2DAA2D,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC;KACb;IACD,MAAM,cAAc,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IAChC,MAAM,eAAe,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACjC,MAAM,eAAe,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IAEjC,sBAAsB;IACtB,MAAM,WAAW,GAAG,eAAe,GAAG,GAAG,GAAG,eAAe,CAAC;IAC5D,MAAM,cAAc,GAAG,qBAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChG,MAAM,qBAAqB,GAAG,mBAAS,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IAC/D,IAAI,qBAAqB,KAAK,cAAc,EAAE;QAC5C,KAAK,CACH,oEAAoE,qBAAqB,gBAAgB,cAAc,EAAE,CAC1H,CAAC;QACF,OAAO,IAAI,CAAC;KACb;IAED,gDAAgD;IAChD,IAAI,OAAO,CAAC,MAAM,IAAI,IAAI,EAAE;QAC1B,IAAI,SAAS,CAAC;QACd,IAAI;YACF,SAAS,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC,CAAC;SACrD;QAAC,OAAO,CAAC,EAAE;YACV,KAAK,CAAC,6DAA6D,eAAe,EAAE,CAAC,CAAC;YACtF,OAAO,IAAI,CAAC;SACb;QACD,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAC/B,MAAM,WAAW,GAAG,WAAW,GAAG,SAAS,CAAC,OAAO,EAAE,CAAC;QACtD,IAAI,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE;YAChC,KAAK,CACH,4DAA4D,WAAW,aAAa,OAAO,CAAC,MAAM,EAAE,CACrG,CAAC;YACF,OAAO,IAAI,CAAC;SACb;KACF;IAED,eAAe;IACf,IAAI,aAAa,EAAE,SAAS,CAAC;IAC7B,IAAI;QACF,aAAa,GAAG,mBAAS,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;KACnD;IAAC,OAAO,CAAC,EAAE;QACV,KAAK,CAAC,gEAAgE,eAAe,EAAE,CAAC,CAAC;QACzF,OAAO,IAAI,CAAC;KACb;IACD,IAAI;QACF,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;KACvC;IAAC,OAAO,CAAC,EAAE;QACV,KAAK,CAAC,6DAA6D,aAAa,EAAE,CAAC,CAAC;QACpF,OAAO,IAAI,CAAC;KACb;IACD,KAAK,CAAC,4CAA4C,SAAS,EAAE,CAAC,CAAC;IAC/D,OAAO,SAAS,CAAC;AACnB,CAAC;AArED,8DAqEC;AAED,SAAgB,gBAAgB,CAC9B,KAAa,EACb,IAAS,EACT,SAAiB,EACjB,UAAwB,EAAE;IAE1B,KAAK,CAAC,+BAA+B,KAAK,EAAE,CAAC,CAAC;IAC9C,KAAK,CAAC,8BAA8B,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5D,KAAK,CAAC,mCAAmC,SAAS,EAAE,CAAC,CAAC;IACtD,KAAK,CAAC,iCAAiC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAClE,KAAK,CAAC,8BAA8B,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5D,MAAM,SAAS,GAAG,yBAAyB,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;IACvE,KAAK,CAAC,mCAAmC,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;IACtE,IAAI,SAAS,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IACpC,IAAI,CAAC,gBAAC,CAAC,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IAC9C,OAAO,IAAI,CAAC;AACd,CAAC;AAhBD,4CAgBC"}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@prairielearn/signed-token",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/PrairieLearn/PrairieLearn.git",
8
+ "directory": "packages/signed-token"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch --preserveWatchOutput"
13
+ },
14
+ "devDependencies": {
15
+ "@prairielearn/tsconfig": "*",
16
+ "@types/debug": "^4.1.7",
17
+ "@types/node": "^18.14.2",
18
+ "mocha": "^10.2.0",
19
+ "typescript": "^4.9.5"
20
+ },
21
+ "dependencies": {
22
+ "base64url": "^3.0.1",
23
+ "debug": "^4.3.4",
24
+ "lodash": "^4.17.21"
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ import base64url from 'base64url';
2
+ import debugModule from 'debug';
3
+ import _ from 'lodash';
4
+ import crypto from 'node:crypto';
5
+
6
+ const debug = debugModule('prairielearn:csrf');
7
+ const sep = '.';
8
+
9
+ interface CheckOptions {
10
+ maxAge?: number;
11
+ }
12
+
13
+ export function generateSignedToken(data: any, secretKey: string) {
14
+ debug(`generateSignedToken(): data = ${JSON.stringify(data)}`);
15
+ debug(`generateSignedToken(): secretKey = ${secretKey}`);
16
+ const dataJSON = JSON.stringify(data);
17
+ const dataString = base64url.encode(dataJSON);
18
+ const dateString = new Date().getTime().toString(36);
19
+ const checkString = dateString + sep + dataString;
20
+ const signature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');
21
+ const encodedSignature = base64url.encode(signature);
22
+ debug(
23
+ `generateSignedToken(): ${JSON.stringify({
24
+ dataString,
25
+ dateString,
26
+ checkString,
27
+ encodedSignature,
28
+ })}`
29
+ );
30
+ const token = encodedSignature + sep + checkString;
31
+ debug(`generateSignedToken(): token = ${token}`);
32
+ return token;
33
+ }
34
+
35
+ export function getCheckedSignedTokenData(
36
+ token: string,
37
+ secretKey: string,
38
+ options: CheckOptions = {}
39
+ ) {
40
+ debug(`getCheckedSignedTokenData(): token = ${token}`);
41
+ debug(`getCheckedSignedTokenData(): secretKey = ${secretKey}`);
42
+ debug(`getCheckedSignedTokenData(): options = ${JSON.stringify(options)}`);
43
+ if (!_.isString(token)) {
44
+ debug(`getCheckedSignedTokenData(): FAIL - token is not string`);
45
+ return null;
46
+ }
47
+
48
+ // break token apart into the three components
49
+ const match = token.split(sep);
50
+ if (match == null) {
51
+ debug(`getCheckedSignedTokenData(): FAIL - could not split token`);
52
+ return null;
53
+ }
54
+ const tokenSignature = match[0];
55
+ const tokenDateString = match[1];
56
+ const tokenDataString = match[2];
57
+
58
+ // check the signature
59
+ const checkString = tokenDateString + sep + tokenDataString;
60
+ const checkSignature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');
61
+ const encodedCheckSignature = base64url.encode(checkSignature);
62
+ if (encodedCheckSignature !== tokenSignature) {
63
+ debug(
64
+ `getCheckedSignedTokenData(): FAIL - signature mismatch: checkSig=${encodedCheckSignature} != tokenSig=${tokenSignature}`
65
+ );
66
+ return null;
67
+ }
68
+
69
+ // check the age if we have the maxAge parameter
70
+ if (options.maxAge != null) {
71
+ let tokenDate;
72
+ try {
73
+ tokenDate = new Date(parseInt(tokenDateString, 36));
74
+ } catch (e) {
75
+ debug(`getCheckedSignedTokenData(): FAIL - could not parse date: ${tokenDateString}`);
76
+ return null;
77
+ }
78
+ const currentTime = Date.now();
79
+ const elapsedTime = currentTime - tokenDate.getTime();
80
+ if (elapsedTime > options.maxAge) {
81
+ debug(
82
+ `getCheckedSignedTokenData(): FAIL - too old: elapsedTime=${elapsedTime} > maxAge=${options.maxAge}`
83
+ );
84
+ return null;
85
+ }
86
+ }
87
+
88
+ // get the data
89
+ let tokenDataJSON, tokenData;
90
+ try {
91
+ tokenDataJSON = base64url.decode(tokenDataString);
92
+ } catch (e) {
93
+ debug(`getCheckedSignedTokenData(): FAIL - could not base64 decode: ${tokenDateString}`);
94
+ return null;
95
+ }
96
+ try {
97
+ tokenData = JSON.parse(tokenDataJSON);
98
+ } catch (e) {
99
+ debug(`getCheckedSignedTokenData(): FAIL - could not parse JSON: ${tokenDataJSON}`);
100
+ return null;
101
+ }
102
+ debug(`getCheckedSignedTokenData(): tokenData = ${tokenData}`);
103
+ return tokenData;
104
+ }
105
+
106
+ export function checkSignedToken(
107
+ token: string,
108
+ data: any,
109
+ secretKey: string,
110
+ options: CheckOptions = {}
111
+ ) {
112
+ debug(`checkSignedToken(): token = ${token}`);
113
+ debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);
114
+ debug(`checkSignedToken(): secretKey = ${secretKey}`);
115
+ debug(`checkSignedToken(): options = ${JSON.stringify(options)}`);
116
+ debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);
117
+ const tokenData = getCheckedSignedTokenData(token, secretKey, options);
118
+ debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);
119
+ if (tokenData == null) return false;
120
+ if (!_.isEqual(data, tokenData)) return false;
121
+ return true;
122
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "@prairielearn/tsconfig",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ },
7
+ }