@prairielearn/signed-token 2.0.23 → 3.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @prairielearn/signed-token
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4cbe086: Add `generatePrefixCsrfToken()` and `checkSignedTokenPrefix()` functions for prefix-based CSRF token validation. These functions allow generating a single CSRF token that is valid for all URLs under a given prefix, which is useful for tRPC and similar APIs where multiple endpoints share a common base path.
8
+
9
+ ### Patch Changes
10
+
11
+ - f929a68: Replace lodash with es-toolkit
12
+
13
+ ## 3.0.0
14
+
15
+ ### Major Changes
16
+
17
+ - 3914bb4: Upgrade to Node 24
18
+
3
19
  ## 2.0.23
4
20
 
5
21
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -4,5 +4,31 @@ interface CheckOptions {
4
4
  export declare function generateSignedToken(data: any, secretKey: string): string;
5
5
  export declare function getCheckedSignedTokenData(token: string, secretKey: string, options?: CheckOptions): any;
6
6
  export declare function checkSignedToken(token: string, data: any, secretKey: string, options?: CheckOptions): boolean;
7
+ /**
8
+ * Generates a CSRF token that is valid for a URL prefix instead of an exact URL.
9
+ * This is useful for tRPC and similar APIs where a single token should be valid
10
+ * for all sub-routes under a prefix (e.g., `/foo/bar/trpc` is valid for
11
+ * `/foo/bar/trpc/getUser` and `/foo/bar/trpc/updateUser`).
12
+ */
13
+ export declare function generatePrefixCsrfToken(data: {
14
+ url: string;
15
+ authn_user_id: string;
16
+ }, secretKey: string): string;
17
+ /**
18
+ * Validates a prefix-based CSRF token. The token's URL must be a prefix of the
19
+ * request URL for validation to succeed.
20
+ *
21
+ * @param token - The CSRF token to validate
22
+ * @param requestData - The request URL and authenticated user ID
23
+ * @param requestData.url - The request URL to validate against
24
+ * @param requestData.authn_user_id - The authenticated user ID to validate against
25
+ * @param secretKey - The secret key used for signing
26
+ * @param options - Optional settings like maxAge
27
+ * @returns true if the token is valid, false otherwise
28
+ */
29
+ export declare function checkSignedTokenPrefix(token: string, requestData: {
30
+ url: string;
31
+ authn_user_id: string;
32
+ }, secretKey: string, options?: CheckOptions): boolean;
7
33
  export {};
8
34
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,UAAU,YAAY;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,UAoB/D;AAED,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,YAAiB,OAkE3B;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,GAAG,EACT,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,YAAiB,WAY3B","sourcesContent":["import crypto from 'node:crypto';\n\nimport base64url from 'base64url';\nimport debugfn from 'debug';\nimport _ from 'lodash';\n\nconst debug = debugfn('prairielearn:csrf');\nconst sep = '.';\n\ninterface CheckOptions {\n maxAge?: number;\n}\n\nexport function generateSignedToken(data: any, secretKey: string) {\n debug(`generateSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`generateSignedToken(): secretKey = ${secretKey}`);\n const dataJSON = JSON.stringify(data);\n const dataString = base64url.default.encode(dataJSON);\n const dateString = Date.now().toString(36);\n const checkString = dateString + sep + dataString;\n const signature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedSignature = base64url.default.encode(signature);\n debug(\n `generateSignedToken(): ${JSON.stringify({\n dataString,\n dateString,\n checkString,\n encodedSignature,\n })}`,\n );\n const token = encodedSignature + sep + checkString;\n debug(`generateSignedToken(): token = ${token}`);\n return token;\n}\n\nexport function getCheckedSignedTokenData(\n token: string,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`getCheckedSignedTokenData(): token = ${token}`);\n debug(`getCheckedSignedTokenData(): secretKey = ${secretKey}`);\n debug(`getCheckedSignedTokenData(): options = ${JSON.stringify(options)}`);\n if (typeof token !== 'string') {\n debug('getCheckedSignedTokenData(): FAIL - token is not string');\n return null;\n }\n\n // break token apart into the three components\n const match = token.split(sep);\n if (match == null) {\n debug('getCheckedSignedTokenData(): FAIL - could not split token');\n return null;\n }\n const tokenSignature = match[0];\n const tokenDateString = match[1];\n const tokenDataString = match[2];\n\n // check the signature\n const checkString = tokenDateString + sep + tokenDataString;\n const checkSignature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedCheckSignature = base64url.default.encode(checkSignature);\n if (encodedCheckSignature !== tokenSignature) {\n debug(\n `getCheckedSignedTokenData(): FAIL - signature mismatch: checkSig=${encodedCheckSignature} != tokenSig=${tokenSignature}`,\n );\n return null;\n }\n\n // check the age if we have the maxAge parameter\n if (options.maxAge != null) {\n let tokenDate;\n try {\n tokenDate = new Date(Number.parseInt(tokenDateString, 36));\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse date: ${tokenDateString}`);\n return null;\n }\n const currentTime = Date.now();\n const elapsedTime = currentTime - tokenDate.getTime();\n if (elapsedTime > options.maxAge) {\n debug(\n `getCheckedSignedTokenData(): FAIL - too old: elapsedTime=${elapsedTime} > maxAge=${options.maxAge}`,\n );\n return null;\n }\n }\n\n // get the data\n let tokenDataJSON, tokenData;\n try {\n tokenDataJSON = base64url.default.decode(tokenDataString);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not base64 decode: ${tokenDateString}`);\n return null;\n }\n try {\n tokenData = JSON.parse(tokenDataJSON);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse JSON: ${tokenDataJSON}`);\n return null;\n }\n debug(`getCheckedSignedTokenData(): tokenData = ${tokenData}`);\n return tokenData;\n}\n\nexport function checkSignedToken(\n token: string,\n data: any,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`checkSignedToken(): token = ${token}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`checkSignedToken(): secretKey = ${secretKey}`);\n debug(`checkSignedToken(): options = ${JSON.stringify(options)}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n const tokenData = getCheckedSignedTokenData(token, secretKey, options);\n debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);\n if (tokenData == null) return false;\n if (!_.isEqual(data, tokenData)) return false;\n return true;\n}\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,UAAU,YAAY;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,UAoB/D;AAED,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,YAAiB,OAkE3B;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,GAAG,EACT,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,YAAiB,WAY3B;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,EAC5C,SAAS,EAAE,MAAM,UAGlB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,EACnD,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,YAAiB,GACzB,OAAO,CAkCT","sourcesContent":["import crypto from 'node:crypto';\n\nimport base64url from 'base64url';\nimport debugfn from 'debug';\nimport { isEqual } from 'es-toolkit';\n\nconst debug = debugfn('prairielearn:csrf');\nconst sep = '.';\n\ninterface CheckOptions {\n maxAge?: number;\n}\n\nexport function generateSignedToken(data: any, secretKey: string) {\n debug(`generateSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`generateSignedToken(): secretKey = ${secretKey}`);\n const dataJSON = JSON.stringify(data);\n const dataString = base64url.default.encode(dataJSON);\n const dateString = Date.now().toString(36);\n const checkString = dateString + sep + dataString;\n const signature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedSignature = base64url.default.encode(signature);\n debug(\n `generateSignedToken(): ${JSON.stringify({\n dataString,\n dateString,\n checkString,\n encodedSignature,\n })}`,\n );\n const token = encodedSignature + sep + checkString;\n debug(`generateSignedToken(): token = ${token}`);\n return token;\n}\n\nexport function getCheckedSignedTokenData(\n token: string,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`getCheckedSignedTokenData(): token = ${token}`);\n debug(`getCheckedSignedTokenData(): secretKey = ${secretKey}`);\n debug(`getCheckedSignedTokenData(): options = ${JSON.stringify(options)}`);\n if (typeof token !== 'string') {\n debug('getCheckedSignedTokenData(): FAIL - token is not string');\n return null;\n }\n\n // break token apart into the three components\n const match = token.split(sep);\n if (match == null) {\n debug('getCheckedSignedTokenData(): FAIL - could not split token');\n return null;\n }\n const tokenSignature = match[0];\n const tokenDateString = match[1];\n const tokenDataString = match[2];\n\n // check the signature\n const checkString = tokenDateString + sep + tokenDataString;\n const checkSignature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedCheckSignature = base64url.default.encode(checkSignature);\n if (encodedCheckSignature !== tokenSignature) {\n debug(\n `getCheckedSignedTokenData(): FAIL - signature mismatch: checkSig=${encodedCheckSignature} != tokenSig=${tokenSignature}`,\n );\n return null;\n }\n\n // check the age if we have the maxAge parameter\n if (options.maxAge != null) {\n let tokenDate;\n try {\n tokenDate = new Date(Number.parseInt(tokenDateString, 36));\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse date: ${tokenDateString}`);\n return null;\n }\n const currentTime = Date.now();\n const elapsedTime = currentTime - tokenDate.getTime();\n if (elapsedTime > options.maxAge) {\n debug(\n `getCheckedSignedTokenData(): FAIL - too old: elapsedTime=${elapsedTime} > maxAge=${options.maxAge}`,\n );\n return null;\n }\n }\n\n // get the data\n let tokenDataJSON, tokenData;\n try {\n tokenDataJSON = base64url.default.decode(tokenDataString);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not base64 decode: ${tokenDateString}`);\n return null;\n }\n try {\n tokenData = JSON.parse(tokenDataJSON);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse JSON: ${tokenDataJSON}`);\n return null;\n }\n debug(`getCheckedSignedTokenData(): tokenData = ${tokenData}`);\n return tokenData;\n}\n\nexport function checkSignedToken(\n token: string,\n data: any,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`checkSignedToken(): token = ${token}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`checkSignedToken(): secretKey = ${secretKey}`);\n debug(`checkSignedToken(): options = ${JSON.stringify(options)}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n const tokenData = getCheckedSignedTokenData(token, secretKey, options);\n debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);\n if (tokenData == null) return false;\n if (!isEqual(data, tokenData)) return false;\n return true;\n}\n\n/**\n * Generates a CSRF token that is valid for a URL prefix instead of an exact URL.\n * This is useful for tRPC and similar APIs where a single token should be valid\n * for all sub-routes under a prefix (e.g., `/foo/bar/trpc` is valid for\n * `/foo/bar/trpc/getUser` and `/foo/bar/trpc/updateUser`).\n */\nexport function generatePrefixCsrfToken(\n data: { url: string; authn_user_id: string },\n secretKey: string,\n) {\n return generateSignedToken({ ...data, type: 'prefix' }, secretKey);\n}\n\n/**\n * Validates a prefix-based CSRF token. The token's URL must be a prefix of the\n * request URL for validation to succeed.\n *\n * @param token - The CSRF token to validate\n * @param requestData - The request URL and authenticated user ID\n * @param requestData.url - The request URL to validate against\n * @param requestData.authn_user_id - The authenticated user ID to validate against\n * @param secretKey - The secret key used for signing\n * @param options - Optional settings like maxAge\n * @returns true if the token is valid, false otherwise\n */\nexport function checkSignedTokenPrefix(\n token: string,\n requestData: { url: string; authn_user_id: string },\n secretKey: string,\n options: CheckOptions = {},\n): boolean {\n debug(`checkSignedTokenPrefix(): token = ${token}`);\n debug(`checkSignedTokenPrefix(): requestData = ${JSON.stringify(requestData)}`);\n\n const tokenData = getCheckedSignedTokenData(token, secretKey, options);\n if (tokenData == null) return false;\n\n // Verify this is a prefix token (prevents token type confusion)\n if (tokenData.type !== 'prefix') {\n debug('checkSignedTokenPrefix(): FAIL - token type is not prefix');\n return false;\n }\n\n // Verify user ID matches exactly\n if (tokenData.authn_user_id !== requestData.authn_user_id) {\n debug('checkSignedTokenPrefix(): FAIL - authn_user_id mismatch');\n return false;\n }\n\n // Verify the request URL starts with the token's prefix URL.\n // We treat the prefix as implicitly ending with a trailing slash, so\n // `/test` matches `/test`, `/test/`, and `/test/nested`, but NOT `/testy`.\n const prefixUrl = tokenData.url;\n const requestUrl = requestData.url;\n const normalizedPrefix = prefixUrl.endsWith('/') ? prefixUrl : prefixUrl + '/';\n if (requestUrl !== prefixUrl && !requestUrl.startsWith(normalizedPrefix)) {\n debug(\n `checkSignedTokenPrefix(): FAIL - URL prefix mismatch: ${requestUrl} does not start with ${prefixUrl}`,\n );\n return false;\n }\n\n debug('checkSignedTokenPrefix(): SUCCESS');\n return true;\n}\n"]}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import base64url from 'base64url';
3
3
  import debugfn from 'debug';
4
- import _ from 'lodash';
4
+ import { isEqual } from 'es-toolkit';
5
5
  const debug = debugfn('prairielearn:csrf');
6
6
  const sep = '.';
7
7
  export function generateSignedToken(data, secretKey) {
@@ -94,8 +94,58 @@ export function checkSignedToken(token, data, secretKey, options = {}) {
94
94
  debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);
95
95
  if (tokenData == null)
96
96
  return false;
97
- if (!_.isEqual(data, tokenData))
97
+ if (!isEqual(data, tokenData))
98
98
  return false;
99
99
  return true;
100
100
  }
101
+ /**
102
+ * Generates a CSRF token that is valid for a URL prefix instead of an exact URL.
103
+ * This is useful for tRPC and similar APIs where a single token should be valid
104
+ * for all sub-routes under a prefix (e.g., `/foo/bar/trpc` is valid for
105
+ * `/foo/bar/trpc/getUser` and `/foo/bar/trpc/updateUser`).
106
+ */
107
+ export function generatePrefixCsrfToken(data, secretKey) {
108
+ return generateSignedToken({ ...data, type: 'prefix' }, secretKey);
109
+ }
110
+ /**
111
+ * Validates a prefix-based CSRF token. The token's URL must be a prefix of the
112
+ * request URL for validation to succeed.
113
+ *
114
+ * @param token - The CSRF token to validate
115
+ * @param requestData - The request URL and authenticated user ID
116
+ * @param requestData.url - The request URL to validate against
117
+ * @param requestData.authn_user_id - The authenticated user ID to validate against
118
+ * @param secretKey - The secret key used for signing
119
+ * @param options - Optional settings like maxAge
120
+ * @returns true if the token is valid, false otherwise
121
+ */
122
+ export function checkSignedTokenPrefix(token, requestData, secretKey, options = {}) {
123
+ debug(`checkSignedTokenPrefix(): token = ${token}`);
124
+ debug(`checkSignedTokenPrefix(): requestData = ${JSON.stringify(requestData)}`);
125
+ const tokenData = getCheckedSignedTokenData(token, secretKey, options);
126
+ if (tokenData == null)
127
+ return false;
128
+ // Verify this is a prefix token (prevents token type confusion)
129
+ if (tokenData.type !== 'prefix') {
130
+ debug('checkSignedTokenPrefix(): FAIL - token type is not prefix');
131
+ return false;
132
+ }
133
+ // Verify user ID matches exactly
134
+ if (tokenData.authn_user_id !== requestData.authn_user_id) {
135
+ debug('checkSignedTokenPrefix(): FAIL - authn_user_id mismatch');
136
+ return false;
137
+ }
138
+ // Verify the request URL starts with the token's prefix URL.
139
+ // We treat the prefix as implicitly ending with a trailing slash, so
140
+ // `/test` matches `/test`, `/test/`, and `/test/nested`, but NOT `/testy`.
141
+ const prefixUrl = tokenData.url;
142
+ const requestUrl = requestData.url;
143
+ const normalizedPrefix = prefixUrl.endsWith('/') ? prefixUrl : prefixUrl + '/';
144
+ if (requestUrl !== prefixUrl && !requestUrl.startsWith(normalizedPrefix)) {
145
+ debug(`checkSignedTokenPrefix(): FAIL - URL prefix mismatch: ${requestUrl} does not start with ${prefixUrl}`);
146
+ return false;
147
+ }
148
+ debug('checkSignedTokenPrefix(): SUCCESS');
149
+ return true;
150
+ }
101
151
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import crypto from 'node:crypto';\n\nimport base64url from 'base64url';\nimport debugfn from 'debug';\nimport _ from 'lodash';\n\nconst debug = debugfn('prairielearn:csrf');\nconst sep = '.';\n\ninterface CheckOptions {\n maxAge?: number;\n}\n\nexport function generateSignedToken(data: any, secretKey: string) {\n debug(`generateSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`generateSignedToken(): secretKey = ${secretKey}`);\n const dataJSON = JSON.stringify(data);\n const dataString = base64url.default.encode(dataJSON);\n const dateString = Date.now().toString(36);\n const checkString = dateString + sep + dataString;\n const signature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedSignature = base64url.default.encode(signature);\n debug(\n `generateSignedToken(): ${JSON.stringify({\n dataString,\n dateString,\n checkString,\n encodedSignature,\n })}`,\n );\n const token = encodedSignature + sep + checkString;\n debug(`generateSignedToken(): token = ${token}`);\n return token;\n}\n\nexport function getCheckedSignedTokenData(\n token: string,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`getCheckedSignedTokenData(): token = ${token}`);\n debug(`getCheckedSignedTokenData(): secretKey = ${secretKey}`);\n debug(`getCheckedSignedTokenData(): options = ${JSON.stringify(options)}`);\n if (typeof token !== 'string') {\n debug('getCheckedSignedTokenData(): FAIL - token is not string');\n return null;\n }\n\n // break token apart into the three components\n const match = token.split(sep);\n if (match == null) {\n debug('getCheckedSignedTokenData(): FAIL - could not split token');\n return null;\n }\n const tokenSignature = match[0];\n const tokenDateString = match[1];\n const tokenDataString = match[2];\n\n // check the signature\n const checkString = tokenDateString + sep + tokenDataString;\n const checkSignature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedCheckSignature = base64url.default.encode(checkSignature);\n if (encodedCheckSignature !== tokenSignature) {\n debug(\n `getCheckedSignedTokenData(): FAIL - signature mismatch: checkSig=${encodedCheckSignature} != tokenSig=${tokenSignature}`,\n );\n return null;\n }\n\n // check the age if we have the maxAge parameter\n if (options.maxAge != null) {\n let tokenDate;\n try {\n tokenDate = new Date(Number.parseInt(tokenDateString, 36));\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse date: ${tokenDateString}`);\n return null;\n }\n const currentTime = Date.now();\n const elapsedTime = currentTime - tokenDate.getTime();\n if (elapsedTime > options.maxAge) {\n debug(\n `getCheckedSignedTokenData(): FAIL - too old: elapsedTime=${elapsedTime} > maxAge=${options.maxAge}`,\n );\n return null;\n }\n }\n\n // get the data\n let tokenDataJSON, tokenData;\n try {\n tokenDataJSON = base64url.default.decode(tokenDataString);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not base64 decode: ${tokenDateString}`);\n return null;\n }\n try {\n tokenData = JSON.parse(tokenDataJSON);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse JSON: ${tokenDataJSON}`);\n return null;\n }\n debug(`getCheckedSignedTokenData(): tokenData = ${tokenData}`);\n return tokenData;\n}\n\nexport function checkSignedToken(\n token: string,\n data: any,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`checkSignedToken(): token = ${token}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`checkSignedToken(): secretKey = ${secretKey}`);\n debug(`checkSignedToken(): options = ${JSON.stringify(options)}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n const tokenData = getCheckedSignedTokenData(token, secretKey, options);\n debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);\n if (tokenData == null) return false;\n if (!_.isEqual(data, tokenData)) return false;\n return true;\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import crypto from 'node:crypto';\n\nimport base64url from 'base64url';\nimport debugfn from 'debug';\nimport { isEqual } from 'es-toolkit';\n\nconst debug = debugfn('prairielearn:csrf');\nconst sep = '.';\n\ninterface CheckOptions {\n maxAge?: number;\n}\n\nexport function generateSignedToken(data: any, secretKey: string) {\n debug(`generateSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`generateSignedToken(): secretKey = ${secretKey}`);\n const dataJSON = JSON.stringify(data);\n const dataString = base64url.default.encode(dataJSON);\n const dateString = Date.now().toString(36);\n const checkString = dateString + sep + dataString;\n const signature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedSignature = base64url.default.encode(signature);\n debug(\n `generateSignedToken(): ${JSON.stringify({\n dataString,\n dateString,\n checkString,\n encodedSignature,\n })}`,\n );\n const token = encodedSignature + sep + checkString;\n debug(`generateSignedToken(): token = ${token}`);\n return token;\n}\n\nexport function getCheckedSignedTokenData(\n token: string,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`getCheckedSignedTokenData(): token = ${token}`);\n debug(`getCheckedSignedTokenData(): secretKey = ${secretKey}`);\n debug(`getCheckedSignedTokenData(): options = ${JSON.stringify(options)}`);\n if (typeof token !== 'string') {\n debug('getCheckedSignedTokenData(): FAIL - token is not string');\n return null;\n }\n\n // break token apart into the three components\n const match = token.split(sep);\n if (match == null) {\n debug('getCheckedSignedTokenData(): FAIL - could not split token');\n return null;\n }\n const tokenSignature = match[0];\n const tokenDateString = match[1];\n const tokenDataString = match[2];\n\n // check the signature\n const checkString = tokenDateString + sep + tokenDataString;\n const checkSignature = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');\n const encodedCheckSignature = base64url.default.encode(checkSignature);\n if (encodedCheckSignature !== tokenSignature) {\n debug(\n `getCheckedSignedTokenData(): FAIL - signature mismatch: checkSig=${encodedCheckSignature} != tokenSig=${tokenSignature}`,\n );\n return null;\n }\n\n // check the age if we have the maxAge parameter\n if (options.maxAge != null) {\n let tokenDate;\n try {\n tokenDate = new Date(Number.parseInt(tokenDateString, 36));\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse date: ${tokenDateString}`);\n return null;\n }\n const currentTime = Date.now();\n const elapsedTime = currentTime - tokenDate.getTime();\n if (elapsedTime > options.maxAge) {\n debug(\n `getCheckedSignedTokenData(): FAIL - too old: elapsedTime=${elapsedTime} > maxAge=${options.maxAge}`,\n );\n return null;\n }\n }\n\n // get the data\n let tokenDataJSON, tokenData;\n try {\n tokenDataJSON = base64url.default.decode(tokenDataString);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not base64 decode: ${tokenDateString}`);\n return null;\n }\n try {\n tokenData = JSON.parse(tokenDataJSON);\n } catch {\n debug(`getCheckedSignedTokenData(): FAIL - could not parse JSON: ${tokenDataJSON}`);\n return null;\n }\n debug(`getCheckedSignedTokenData(): tokenData = ${tokenData}`);\n return tokenData;\n}\n\nexport function checkSignedToken(\n token: string,\n data: any,\n secretKey: string,\n options: CheckOptions = {},\n) {\n debug(`checkSignedToken(): token = ${token}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n debug(`checkSignedToken(): secretKey = ${secretKey}`);\n debug(`checkSignedToken(): options = ${JSON.stringify(options)}`);\n debug(`checkSignedToken(): data = ${JSON.stringify(data)}`);\n const tokenData = getCheckedSignedTokenData(token, secretKey, options);\n debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);\n if (tokenData == null) return false;\n if (!isEqual(data, tokenData)) return false;\n return true;\n}\n\n/**\n * Generates a CSRF token that is valid for a URL prefix instead of an exact URL.\n * This is useful for tRPC and similar APIs where a single token should be valid\n * for all sub-routes under a prefix (e.g., `/foo/bar/trpc` is valid for\n * `/foo/bar/trpc/getUser` and `/foo/bar/trpc/updateUser`).\n */\nexport function generatePrefixCsrfToken(\n data: { url: string; authn_user_id: string },\n secretKey: string,\n) {\n return generateSignedToken({ ...data, type: 'prefix' }, secretKey);\n}\n\n/**\n * Validates a prefix-based CSRF token. The token's URL must be a prefix of the\n * request URL for validation to succeed.\n *\n * @param token - The CSRF token to validate\n * @param requestData - The request URL and authenticated user ID\n * @param requestData.url - The request URL to validate against\n * @param requestData.authn_user_id - The authenticated user ID to validate against\n * @param secretKey - The secret key used for signing\n * @param options - Optional settings like maxAge\n * @returns true if the token is valid, false otherwise\n */\nexport function checkSignedTokenPrefix(\n token: string,\n requestData: { url: string; authn_user_id: string },\n secretKey: string,\n options: CheckOptions = {},\n): boolean {\n debug(`checkSignedTokenPrefix(): token = ${token}`);\n debug(`checkSignedTokenPrefix(): requestData = ${JSON.stringify(requestData)}`);\n\n const tokenData = getCheckedSignedTokenData(token, secretKey, options);\n if (tokenData == null) return false;\n\n // Verify this is a prefix token (prevents token type confusion)\n if (tokenData.type !== 'prefix') {\n debug('checkSignedTokenPrefix(): FAIL - token type is not prefix');\n return false;\n }\n\n // Verify user ID matches exactly\n if (tokenData.authn_user_id !== requestData.authn_user_id) {\n debug('checkSignedTokenPrefix(): FAIL - authn_user_id mismatch');\n return false;\n }\n\n // Verify the request URL starts with the token's prefix URL.\n // We treat the prefix as implicitly ending with a trailing slash, so\n // `/test` matches `/test`, `/test/`, and `/test/nested`, but NOT `/testy`.\n const prefixUrl = tokenData.url;\n const requestUrl = requestData.url;\n const normalizedPrefix = prefixUrl.endsWith('/') ? prefixUrl : prefixUrl + '/';\n if (requestUrl !== prefixUrl && !requestUrl.startsWith(normalizedPrefix)) {\n debug(\n `checkSignedTokenPrefix(): FAIL - URL prefix mismatch: ${requestUrl} does not start with ${prefixUrl}`,\n );\n return false;\n }\n\n debug('checkSignedTokenPrefix(): SUCCESS');\n return true;\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"","sourcesContent":["import { assert, describe, it } from 'vitest';\n\nimport {\n checkSignedToken,\n checkSignedTokenPrefix,\n generatePrefixCsrfToken,\n generateSignedToken,\n getCheckedSignedTokenData,\n} from './index.js';\n\nconst SECRET_KEY = 'test-secret-key';\nconst TEST_DATA = { url: '/test', authn_user_id: '123' };\n\ndescribe('generateSignedToken', () => {\n it('generates a token that can be validated', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n assert.isString(token);\n assert.isTrue(checkSignedToken(token, TEST_DATA, SECRET_KEY));\n });\n\n it('fails validation with wrong data', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedToken(token, { url: '/other', authn_user_id: '123' }, SECRET_KEY));\n });\n\n it('fails validation with wrong secret key', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedToken(token, TEST_DATA, 'wrong-secret'));\n });\n});\n\ndescribe('getCheckedSignedTokenData', () => {\n it('returns null for invalid tokens', () => {\n assert.isNull(getCheckedSignedTokenData('invalid', SECRET_KEY));\n assert.isNull(getCheckedSignedTokenData('', SECRET_KEY));\n assert.isNull(getCheckedSignedTokenData(123 as any, SECRET_KEY));\n });\n\n it('returns token data for valid tokens', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n const result = getCheckedSignedTokenData(token, SECRET_KEY);\n assert.deepEqual(result, TEST_DATA);\n });\n});\n\ndescribe('generatePrefixCsrfToken', () => {\n it('generates a token with type prefix', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n const tokenData = getCheckedSignedTokenData(token, SECRET_KEY);\n assert.equal(tokenData.type, 'prefix');\n assert.equal(tokenData.url, TEST_DATA.url);\n assert.equal(tokenData.authn_user_id, TEST_DATA.authn_user_id);\n });\n});\n\ndescribe('checkSignedTokenPrefix', () => {\n it('validates token when request URL matches prefix exactly', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isTrue(checkSignedTokenPrefix(token, TEST_DATA, SECRET_KEY));\n });\n\n it('validates token when request URL starts with prefix', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n // We allow the route itself, both with and without a trailing slash.\n assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test' }, SECRET_KEY));\n assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/' }, SECRET_KEY));\n\n // We allow deeply nested routes as well.\n assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested' }, SECRET_KEY));\n assert.isTrue(\n checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested/method' }, SECRET_KEY),\n );\n });\n\n it('rejects token when request URL does not start with prefix', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/other/path' }, SECRET_KEY));\n\n // We'll forbid paths that match the prefix only partially. In other words,\n // we'll treat the prefix as if it implicitly ends with a trailing slash.\n assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/testy' }, SECRET_KEY));\n });\n\n it('rejects token when user ID does not match', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(\n checkSignedTokenPrefix(token, { ...TEST_DATA, authn_user_id: '456' }, SECRET_KEY),\n );\n });\n\n it('rejects non-prefix tokens', () => {\n // Generate a regular token (not a prefix token)\n const regularToken = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n // Should fail because it doesn't have type: 'prefix'\n assert.isFalse(checkSignedTokenPrefix(regularToken, TEST_DATA, SECRET_KEY));\n });\n\n it('rejects tampered tokens', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n // Tamper with the token\n const tamperedToken = token.slice(0, -5) + 'XXXXX';\n assert.isFalse(checkSignedTokenPrefix(tamperedToken, TEST_DATA, SECRET_KEY));\n });\n\n it('rejects tokens with wrong secret key', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedTokenPrefix(token, TEST_DATA, 'wrong-secret'));\n });\n\n it('rejects invalid token formats', () => {\n assert.isFalse(checkSignedTokenPrefix('invalid', TEST_DATA, SECRET_KEY));\n assert.isFalse(checkSignedTokenPrefix('', TEST_DATA, SECRET_KEY));\n });\n});\n"]}
@@ -0,0 +1,87 @@
1
+ import { assert, describe, it } from 'vitest';
2
+ import { checkSignedToken, checkSignedTokenPrefix, generatePrefixCsrfToken, generateSignedToken, getCheckedSignedTokenData, } from './index.js';
3
+ const SECRET_KEY = 'test-secret-key';
4
+ const TEST_DATA = { url: '/test', authn_user_id: '123' };
5
+ describe('generateSignedToken', () => {
6
+ it('generates a token that can be validated', () => {
7
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
8
+ assert.isString(token);
9
+ assert.isTrue(checkSignedToken(token, TEST_DATA, SECRET_KEY));
10
+ });
11
+ it('fails validation with wrong data', () => {
12
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
13
+ assert.isFalse(checkSignedToken(token, { url: '/other', authn_user_id: '123' }, SECRET_KEY));
14
+ });
15
+ it('fails validation with wrong secret key', () => {
16
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
17
+ assert.isFalse(checkSignedToken(token, TEST_DATA, 'wrong-secret'));
18
+ });
19
+ });
20
+ describe('getCheckedSignedTokenData', () => {
21
+ it('returns null for invalid tokens', () => {
22
+ assert.isNull(getCheckedSignedTokenData('invalid', SECRET_KEY));
23
+ assert.isNull(getCheckedSignedTokenData('', SECRET_KEY));
24
+ assert.isNull(getCheckedSignedTokenData(123, SECRET_KEY));
25
+ });
26
+ it('returns token data for valid tokens', () => {
27
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
28
+ const result = getCheckedSignedTokenData(token, SECRET_KEY);
29
+ assert.deepEqual(result, TEST_DATA);
30
+ });
31
+ });
32
+ describe('generatePrefixCsrfToken', () => {
33
+ it('generates a token with type prefix', () => {
34
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
35
+ const tokenData = getCheckedSignedTokenData(token, SECRET_KEY);
36
+ assert.equal(tokenData.type, 'prefix');
37
+ assert.equal(tokenData.url, TEST_DATA.url);
38
+ assert.equal(tokenData.authn_user_id, TEST_DATA.authn_user_id);
39
+ });
40
+ });
41
+ describe('checkSignedTokenPrefix', () => {
42
+ it('validates token when request URL matches prefix exactly', () => {
43
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
44
+ assert.isTrue(checkSignedTokenPrefix(token, TEST_DATA, SECRET_KEY));
45
+ });
46
+ it('validates token when request URL starts with prefix', () => {
47
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
48
+ // We allow the route itself, both with and without a trailing slash.
49
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test' }, SECRET_KEY));
50
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/' }, SECRET_KEY));
51
+ // We allow deeply nested routes as well.
52
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested' }, SECRET_KEY));
53
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested/method' }, SECRET_KEY));
54
+ });
55
+ it('rejects token when request URL does not start with prefix', () => {
56
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
57
+ assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/other/path' }, SECRET_KEY));
58
+ // We'll forbid paths that match the prefix only partially. In other words,
59
+ // we'll treat the prefix as if it implicitly ends with a trailing slash.
60
+ assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/testy' }, SECRET_KEY));
61
+ });
62
+ it('rejects token when user ID does not match', () => {
63
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
64
+ assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, authn_user_id: '456' }, SECRET_KEY));
65
+ });
66
+ it('rejects non-prefix tokens', () => {
67
+ // Generate a regular token (not a prefix token)
68
+ const regularToken = generateSignedToken(TEST_DATA, SECRET_KEY);
69
+ // Should fail because it doesn't have type: 'prefix'
70
+ assert.isFalse(checkSignedTokenPrefix(regularToken, TEST_DATA, SECRET_KEY));
71
+ });
72
+ it('rejects tampered tokens', () => {
73
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
74
+ // Tamper with the token
75
+ const tamperedToken = token.slice(0, -5) + 'XXXXX';
76
+ assert.isFalse(checkSignedTokenPrefix(tamperedToken, TEST_DATA, SECRET_KEY));
77
+ });
78
+ it('rejects tokens with wrong secret key', () => {
79
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
80
+ assert.isFalse(checkSignedTokenPrefix(token, TEST_DATA, 'wrong-secret'));
81
+ });
82
+ it('rejects invalid token formats', () => {
83
+ assert.isFalse(checkSignedTokenPrefix('invalid', TEST_DATA, SECRET_KEY));
84
+ assert.isFalse(checkSignedTokenPrefix('', TEST_DATA, SECRET_KEY));
85
+ });
86
+ });
87
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { assert, describe, it } from 'vitest';\n\nimport {\n checkSignedToken,\n checkSignedTokenPrefix,\n generatePrefixCsrfToken,\n generateSignedToken,\n getCheckedSignedTokenData,\n} from './index.js';\n\nconst SECRET_KEY = 'test-secret-key';\nconst TEST_DATA = { url: '/test', authn_user_id: '123' };\n\ndescribe('generateSignedToken', () => {\n it('generates a token that can be validated', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n assert.isString(token);\n assert.isTrue(checkSignedToken(token, TEST_DATA, SECRET_KEY));\n });\n\n it('fails validation with wrong data', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedToken(token, { url: '/other', authn_user_id: '123' }, SECRET_KEY));\n });\n\n it('fails validation with wrong secret key', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedToken(token, TEST_DATA, 'wrong-secret'));\n });\n});\n\ndescribe('getCheckedSignedTokenData', () => {\n it('returns null for invalid tokens', () => {\n assert.isNull(getCheckedSignedTokenData('invalid', SECRET_KEY));\n assert.isNull(getCheckedSignedTokenData('', SECRET_KEY));\n assert.isNull(getCheckedSignedTokenData(123 as any, SECRET_KEY));\n });\n\n it('returns token data for valid tokens', () => {\n const token = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n const result = getCheckedSignedTokenData(token, SECRET_KEY);\n assert.deepEqual(result, TEST_DATA);\n });\n});\n\ndescribe('generatePrefixCsrfToken', () => {\n it('generates a token with type prefix', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n const tokenData = getCheckedSignedTokenData(token, SECRET_KEY);\n assert.equal(tokenData.type, 'prefix');\n assert.equal(tokenData.url, TEST_DATA.url);\n assert.equal(tokenData.authn_user_id, TEST_DATA.authn_user_id);\n });\n});\n\ndescribe('checkSignedTokenPrefix', () => {\n it('validates token when request URL matches prefix exactly', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isTrue(checkSignedTokenPrefix(token, TEST_DATA, SECRET_KEY));\n });\n\n it('validates token when request URL starts with prefix', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n // We allow the route itself, both with and without a trailing slash.\n assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test' }, SECRET_KEY));\n assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/' }, SECRET_KEY));\n\n // We allow deeply nested routes as well.\n assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested' }, SECRET_KEY));\n assert.isTrue(\n checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested/method' }, SECRET_KEY),\n );\n });\n\n it('rejects token when request URL does not start with prefix', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/other/path' }, SECRET_KEY));\n\n // We'll forbid paths that match the prefix only partially. In other words,\n // we'll treat the prefix as if it implicitly ends with a trailing slash.\n assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/testy' }, SECRET_KEY));\n });\n\n it('rejects token when user ID does not match', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(\n checkSignedTokenPrefix(token, { ...TEST_DATA, authn_user_id: '456' }, SECRET_KEY),\n );\n });\n\n it('rejects non-prefix tokens', () => {\n // Generate a regular token (not a prefix token)\n const regularToken = generateSignedToken(TEST_DATA, SECRET_KEY);\n\n // Should fail because it doesn't have type: 'prefix'\n assert.isFalse(checkSignedTokenPrefix(regularToken, TEST_DATA, SECRET_KEY));\n });\n\n it('rejects tampered tokens', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n // Tamper with the token\n const tamperedToken = token.slice(0, -5) + 'XXXXX';\n assert.isFalse(checkSignedTokenPrefix(tamperedToken, TEST_DATA, SECRET_KEY));\n });\n\n it('rejects tokens with wrong secret key', () => {\n const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);\n\n assert.isFalse(checkSignedTokenPrefix(token, TEST_DATA, 'wrong-secret'));\n });\n\n it('rejects invalid token formats', () => {\n assert.isFalse(checkSignedTokenPrefix('invalid', TEST_DATA, SECRET_KEY));\n assert.isFalse(checkSignedTokenPrefix('', TEST_DATA, SECRET_KEY));\n });\n});\n"]}
package/package.json CHANGED
@@ -1,27 +1,33 @@
1
1
  {
2
2
  "name": "@prairielearn/signed-token",
3
- "version": "2.0.23",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/PrairieLearn/PrairieLearn.git",
8
8
  "directory": "packages/signed-token"
9
9
  },
10
+ "engines": {
11
+ "node": ">=24.0.0"
12
+ },
10
13
  "main": "dist/index.js",
11
14
  "scripts": {
12
15
  "build": "tsgo",
13
- "dev": "tsgo --watch --preserveWatchOutput"
16
+ "dev": "tsgo --watch --preserveWatchOutput",
17
+ "test": "vitest run"
14
18
  },
15
19
  "dependencies": {
16
20
  "base64url": "^3.0.1",
17
21
  "debug": "^4.4.3",
18
- "lodash": "^4.17.21"
22
+ "es-toolkit": "^1.43.0"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@prairielearn/tsconfig": "^0.0.0",
22
26
  "@types/debug": "^4.1.12",
23
- "@types/node": "^22.19.5",
27
+ "@types/node": "^24.10.9",
24
28
  "@typescript/native-preview": "^7.0.0-dev.20260106.1",
25
- "typescript": "^5.9.3"
29
+ "@vitest/coverage-v8": "^4.0.17",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^4.0.17"
26
32
  }
27
33
  }
@@ -0,0 +1,126 @@
1
+ import { assert, describe, it } from 'vitest';
2
+
3
+ import {
4
+ checkSignedToken,
5
+ checkSignedTokenPrefix,
6
+ generatePrefixCsrfToken,
7
+ generateSignedToken,
8
+ getCheckedSignedTokenData,
9
+ } from './index.js';
10
+
11
+ const SECRET_KEY = 'test-secret-key';
12
+ const TEST_DATA = { url: '/test', authn_user_id: '123' };
13
+
14
+ describe('generateSignedToken', () => {
15
+ it('generates a token that can be validated', () => {
16
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
17
+
18
+ assert.isString(token);
19
+ assert.isTrue(checkSignedToken(token, TEST_DATA, SECRET_KEY));
20
+ });
21
+
22
+ it('fails validation with wrong data', () => {
23
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
24
+
25
+ assert.isFalse(checkSignedToken(token, { url: '/other', authn_user_id: '123' }, SECRET_KEY));
26
+ });
27
+
28
+ it('fails validation with wrong secret key', () => {
29
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
30
+
31
+ assert.isFalse(checkSignedToken(token, TEST_DATA, 'wrong-secret'));
32
+ });
33
+ });
34
+
35
+ describe('getCheckedSignedTokenData', () => {
36
+ it('returns null for invalid tokens', () => {
37
+ assert.isNull(getCheckedSignedTokenData('invalid', SECRET_KEY));
38
+ assert.isNull(getCheckedSignedTokenData('', SECRET_KEY));
39
+ assert.isNull(getCheckedSignedTokenData(123 as any, SECRET_KEY));
40
+ });
41
+
42
+ it('returns token data for valid tokens', () => {
43
+ const token = generateSignedToken(TEST_DATA, SECRET_KEY);
44
+
45
+ const result = getCheckedSignedTokenData(token, SECRET_KEY);
46
+ assert.deepEqual(result, TEST_DATA);
47
+ });
48
+ });
49
+
50
+ describe('generatePrefixCsrfToken', () => {
51
+ it('generates a token with type prefix', () => {
52
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
53
+
54
+ const tokenData = getCheckedSignedTokenData(token, SECRET_KEY);
55
+ assert.equal(tokenData.type, 'prefix');
56
+ assert.equal(tokenData.url, TEST_DATA.url);
57
+ assert.equal(tokenData.authn_user_id, TEST_DATA.authn_user_id);
58
+ });
59
+ });
60
+
61
+ describe('checkSignedTokenPrefix', () => {
62
+ it('validates token when request URL matches prefix exactly', () => {
63
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
64
+
65
+ assert.isTrue(checkSignedTokenPrefix(token, TEST_DATA, SECRET_KEY));
66
+ });
67
+
68
+ it('validates token when request URL starts with prefix', () => {
69
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
70
+
71
+ // We allow the route itself, both with and without a trailing slash.
72
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test' }, SECRET_KEY));
73
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/' }, SECRET_KEY));
74
+
75
+ // We allow deeply nested routes as well.
76
+ assert.isTrue(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested' }, SECRET_KEY));
77
+ assert.isTrue(
78
+ checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/test/nested/method' }, SECRET_KEY),
79
+ );
80
+ });
81
+
82
+ it('rejects token when request URL does not start with prefix', () => {
83
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
84
+
85
+ assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/other/path' }, SECRET_KEY));
86
+
87
+ // We'll forbid paths that match the prefix only partially. In other words,
88
+ // we'll treat the prefix as if it implicitly ends with a trailing slash.
89
+ assert.isFalse(checkSignedTokenPrefix(token, { ...TEST_DATA, url: '/testy' }, SECRET_KEY));
90
+ });
91
+
92
+ it('rejects token when user ID does not match', () => {
93
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
94
+
95
+ assert.isFalse(
96
+ checkSignedTokenPrefix(token, { ...TEST_DATA, authn_user_id: '456' }, SECRET_KEY),
97
+ );
98
+ });
99
+
100
+ it('rejects non-prefix tokens', () => {
101
+ // Generate a regular token (not a prefix token)
102
+ const regularToken = generateSignedToken(TEST_DATA, SECRET_KEY);
103
+
104
+ // Should fail because it doesn't have type: 'prefix'
105
+ assert.isFalse(checkSignedTokenPrefix(regularToken, TEST_DATA, SECRET_KEY));
106
+ });
107
+
108
+ it('rejects tampered tokens', () => {
109
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
110
+
111
+ // Tamper with the token
112
+ const tamperedToken = token.slice(0, -5) + 'XXXXX';
113
+ assert.isFalse(checkSignedTokenPrefix(tamperedToken, TEST_DATA, SECRET_KEY));
114
+ });
115
+
116
+ it('rejects tokens with wrong secret key', () => {
117
+ const token = generatePrefixCsrfToken(TEST_DATA, SECRET_KEY);
118
+
119
+ assert.isFalse(checkSignedTokenPrefix(token, TEST_DATA, 'wrong-secret'));
120
+ });
121
+
122
+ it('rejects invalid token formats', () => {
123
+ assert.isFalse(checkSignedTokenPrefix('invalid', TEST_DATA, SECRET_KEY));
124
+ assert.isFalse(checkSignedTokenPrefix('', TEST_DATA, SECRET_KEY));
125
+ });
126
+ });
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
 
3
3
  import base64url from 'base64url';
4
4
  import debugfn from 'debug';
5
- import _ from 'lodash';
5
+ import { isEqual } from 'es-toolkit';
6
6
 
7
7
  const debug = debugfn('prairielearn:csrf');
8
8
  const sep = '.';
@@ -118,6 +118,72 @@ export function checkSignedToken(
118
118
  const tokenData = getCheckedSignedTokenData(token, secretKey, options);
119
119
  debug(`checkSignedToken(): tokenData = ${JSON.stringify(tokenData)}`);
120
120
  if (tokenData == null) return false;
121
- if (!_.isEqual(data, tokenData)) return false;
121
+ if (!isEqual(data, tokenData)) return false;
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Generates a CSRF token that is valid for a URL prefix instead of an exact URL.
127
+ * This is useful for tRPC and similar APIs where a single token should be valid
128
+ * for all sub-routes under a prefix (e.g., `/foo/bar/trpc` is valid for
129
+ * `/foo/bar/trpc/getUser` and `/foo/bar/trpc/updateUser`).
130
+ */
131
+ export function generatePrefixCsrfToken(
132
+ data: { url: string; authn_user_id: string },
133
+ secretKey: string,
134
+ ) {
135
+ return generateSignedToken({ ...data, type: 'prefix' }, secretKey);
136
+ }
137
+
138
+ /**
139
+ * Validates a prefix-based CSRF token. The token's URL must be a prefix of the
140
+ * request URL for validation to succeed.
141
+ *
142
+ * @param token - The CSRF token to validate
143
+ * @param requestData - The request URL and authenticated user ID
144
+ * @param requestData.url - The request URL to validate against
145
+ * @param requestData.authn_user_id - The authenticated user ID to validate against
146
+ * @param secretKey - The secret key used for signing
147
+ * @param options - Optional settings like maxAge
148
+ * @returns true if the token is valid, false otherwise
149
+ */
150
+ export function checkSignedTokenPrefix(
151
+ token: string,
152
+ requestData: { url: string; authn_user_id: string },
153
+ secretKey: string,
154
+ options: CheckOptions = {},
155
+ ): boolean {
156
+ debug(`checkSignedTokenPrefix(): token = ${token}`);
157
+ debug(`checkSignedTokenPrefix(): requestData = ${JSON.stringify(requestData)}`);
158
+
159
+ const tokenData = getCheckedSignedTokenData(token, secretKey, options);
160
+ if (tokenData == null) return false;
161
+
162
+ // Verify this is a prefix token (prevents token type confusion)
163
+ if (tokenData.type !== 'prefix') {
164
+ debug('checkSignedTokenPrefix(): FAIL - token type is not prefix');
165
+ return false;
166
+ }
167
+
168
+ // Verify user ID matches exactly
169
+ if (tokenData.authn_user_id !== requestData.authn_user_id) {
170
+ debug('checkSignedTokenPrefix(): FAIL - authn_user_id mismatch');
171
+ return false;
172
+ }
173
+
174
+ // Verify the request URL starts with the token's prefix URL.
175
+ // We treat the prefix as implicitly ending with a trailing slash, so
176
+ // `/test` matches `/test`, `/test/`, and `/test/nested`, but NOT `/testy`.
177
+ const prefixUrl = tokenData.url;
178
+ const requestUrl = requestData.url;
179
+ const normalizedPrefix = prefixUrl.endsWith('/') ? prefixUrl : prefixUrl + '/';
180
+ if (requestUrl !== prefixUrl && !requestUrl.startsWith(normalizedPrefix)) {
181
+ debug(
182
+ `checkSignedTokenPrefix(): FAIL - URL prefix mismatch: ${requestUrl} does not start with ${prefixUrl}`,
183
+ );
184
+ return false;
185
+ }
186
+
187
+ debug('checkSignedTokenPrefix(): SUCCESS');
122
188
  return true;
123
189
  }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ dir: `${import.meta.dirname}/src`,
6
+ coverage: {
7
+ reporter: ['html', 'text-summary', 'cobertura'],
8
+ include: ['src/**/*.{ts,tsx}'],
9
+ },
10
+ },
11
+ });