@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 +16 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -2
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +87 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +11 -5
- package/src/index.test.ts +126 -0
- package/src/index.ts +68 -2
- package/vitest.config.ts +11 -0
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
|
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 (!
|
|
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":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AAEjC,OAAO,SAAS,MAAM,WAAW,CAAC;AAClC,OAAO,OAAO,MAAM,OAAO,CAAC;AAC5B,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAErC,MAAM,KAAK,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;AAC3C,MAAM,GAAG,GAAG,GAAG,CAAC;AAMhB,MAAM,UAAU,mBAAmB,CAAC,IAAS,EAAE,SAAiB,EAAE;IAChE,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,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACtD,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC3C,MAAM,WAAW,GAAG,UAAU,GAAG,GAAG,GAAG,UAAU,CAAC;IAClD,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3F,MAAM,gBAAgB,GAAG,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IAC7D,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;AAAA,CACd;AAED,MAAM,UAAU,yBAAyB,CACvC,KAAa,EACb,SAAiB,EACjB,OAAO,GAAiB,EAAE,EAC1B;IACA,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,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,KAAK,CAAC,yDAAyD,CAAC,CAAC;QACjE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,8CAA8C;IAC9C,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;QAClB,KAAK,CAAC,2DAA2D,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC;IACd,CAAC;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,MAAM,CAAC,UAAU,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChG,MAAM,qBAAqB,GAAG,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IACvE,IAAI,qBAAqB,KAAK,cAAc,EAAE,CAAC;QAC7C,KAAK,CACH,oEAAoE,qBAAqB,gBAAgB,cAAc,EAAE,CAC1H,CAAC;QACF,OAAO,IAAI,CAAC;IACd,CAAC;IAED,gDAAgD;IAChD,IAAI,OAAO,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;QAC3B,IAAI,SAAS,CAAC;QACd,IAAI,CAAC;YACH,SAAS,GAAG,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC,CAAC;QAC7D,CAAC;QAAC,MAAM,CAAC;YACP,KAAK,CAAC,6DAA6D,eAAe,EAAE,CAAC,CAAC;YACtF,OAAO,IAAI,CAAC;QACd,CAAC;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,CAAC;YACjC,KAAK,CACH,4DAA4D,WAAW,aAAa,OAAO,CAAC,MAAM,EAAE,CACrG,CAAC;YACF,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,eAAe;IACf,IAAI,aAAa,EAAE,SAAS,CAAC;IAC7B,IAAI,CAAC;QACH,aAAa,GAAG,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;IAC5D,CAAC;IAAC,MAAM,CAAC;QACP,KAAK,CAAC,gEAAgE,eAAe,EAAE,CAAC,CAAC;QACzF,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC;QACH,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IACxC,CAAC;IAAC,MAAM,CAAC;QACP,KAAK,CAAC,6DAA6D,aAAa,EAAE,CAAC,CAAC;QACpF,OAAO,IAAI,CAAC;IACd,CAAC;IACD,KAAK,CAAC,4CAA4C,SAAS,EAAE,CAAC,CAAC;IAC/D,OAAO,SAAS,CAAC;AAAA,CAClB;AAED,MAAM,UAAU,gBAAgB,CAC9B,KAAa,EACb,IAAS,EACT,SAAiB,EACjB,OAAO,GAAiB,EAAE,EAC1B;IACA,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,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IAC5C,OAAO,IAAI,CAAC;AAAA,CACb;AAED;;;;;GAKG;AACH,MAAM,UAAU,uBAAuB,CACrC,IAA4C,EAC5C,SAAiB,EACjB;IACA,OAAO,mBAAmB,CAAC,EAAE,GAAG,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,SAAS,CAAC,CAAC;AAAA,CACpE;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,sBAAsB,CACpC,KAAa,EACb,WAAmD,EACnD,SAAiB,EACjB,OAAO,GAAiB,EAAE,EACjB;IACT,KAAK,CAAC,qCAAqC,KAAK,EAAE,CAAC,CAAC;IACpD,KAAK,CAAC,2CAA2C,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IAEhF,MAAM,SAAS,GAAG,yBAAyB,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;IACvE,IAAI,SAAS,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IAEpC,gEAAgE;IAChE,IAAI,SAAS,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAChC,KAAK,CAAC,2DAA2D,CAAC,CAAC;QACnE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,iCAAiC;IACjC,IAAI,SAAS,CAAC,aAAa,KAAK,WAAW,CAAC,aAAa,EAAE,CAAC;QAC1D,KAAK,CAAC,yDAAyD,CAAC,CAAC;QACjE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,6DAA6D;IAC7D,qEAAqE;IACrE,2EAA2E;IAC3E,MAAM,SAAS,GAAG,SAAS,CAAC,GAAG,CAAC;IAChC,MAAM,UAAU,GAAG,WAAW,CAAC,GAAG,CAAC;IACnC,MAAM,gBAAgB,GAAG,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC;IAC/E,IAAI,UAAU,KAAK,SAAS,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACzE,KAAK,CACH,yDAAyD,UAAU,wBAAwB,SAAS,EAAE,CACvG,CAAC;QACF,OAAO,KAAK,CAAC;IACf,CAAC;IAED,KAAK,CAAC,mCAAmC,CAAC,CAAC;IAC3C,OAAO,IAAI,CAAC;AAAA,CACb","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 @@
|
|
|
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": "
|
|
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
|
-
"
|
|
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": "^
|
|
27
|
+
"@types/node": "^24.10.9",
|
|
24
28
|
"@typescript/native-preview": "^7.0.0-dev.20260106.1",
|
|
25
|
-
"
|
|
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
|
|
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 (!
|
|
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
|
}
|
package/vitest.config.ts
ADDED