eslint-plugin-jwt-security 2.3.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.
Files changed (79) hide show
  1. package/AGENTS.md +102 -0
  2. package/CHANGELOG.md +161 -0
  3. package/LICENSE +23 -0
  4. package/README.md +158 -0
  5. package/dist/AGENTS.md +102 -0
  6. package/dist/CHANGELOG.md +161 -0
  7. package/dist/LICENSE +23 -0
  8. package/dist/README.md +158 -0
  9. package/dist/package.json +125 -0
  10. package/dist/src/index.d.ts +101 -0
  11. package/dist/src/index.js +171 -0
  12. package/dist/src/oxlint.d.ts +20 -0
  13. package/dist/src/oxlint.js +21 -0
  14. package/dist/src/rules/no-algorithm-confusion/index.d.ts +24 -0
  15. package/dist/src/rules/no-algorithm-confusion/index.js +165 -0
  16. package/dist/src/rules/no-algorithm-none/index.d.ts +24 -0
  17. package/dist/src/rules/no-algorithm-none/index.js +222 -0
  18. package/dist/src/rules/no-decode-without-verify/index.d.ts +23 -0
  19. package/dist/src/rules/no-decode-without-verify/index.js +131 -0
  20. package/dist/src/rules/no-hardcoded-secret/index.d.ts +23 -0
  21. package/dist/src/rules/no-hardcoded-secret/index.js +189 -0
  22. package/dist/src/rules/no-sensitive-payload/index.d.ts +20 -0
  23. package/dist/src/rules/no-sensitive-payload/index.js +103 -0
  24. package/dist/src/rules/no-timestamp-manipulation/index.d.ts +23 -0
  25. package/dist/src/rules/no-timestamp-manipulation/index.js +97 -0
  26. package/dist/src/rules/no-weak-secret/index.d.ts +23 -0
  27. package/dist/src/rules/no-weak-secret/index.js +165 -0
  28. package/dist/src/rules/require-algorithm-whitelist/index.d.ts +24 -0
  29. package/dist/src/rules/require-algorithm-whitelist/index.js +113 -0
  30. package/dist/src/rules/require-audience-validation/index.d.ts +20 -0
  31. package/dist/src/rules/require-audience-validation/index.js +100 -0
  32. package/dist/src/rules/require-expiration/index.d.ts +23 -0
  33. package/dist/src/rules/require-expiration/index.js +136 -0
  34. package/dist/src/rules/require-issued-at/index.d.ts +23 -0
  35. package/dist/src/rules/require-issued-at/index.js +113 -0
  36. package/dist/src/rules/require-issuer-validation/index.d.ts +20 -0
  37. package/dist/src/rules/require-issuer-validation/index.js +100 -0
  38. package/dist/src/rules/require-max-age/index.d.ts +24 -0
  39. package/dist/src/rules/require-max-age/index.js +93 -0
  40. package/dist/src/types/index.d.ts +82 -0
  41. package/dist/src/types/index.js +7 -0
  42. package/dist/src/utils/index.d.ts +98 -0
  43. package/dist/src/utils/index.js +238 -0
  44. package/package.json +125 -0
  45. package/src/__compatibility__/jwt-interface.spec.ts +192 -0
  46. package/src/index.test.ts +47 -0
  47. package/src/index.ts +209 -0
  48. package/src/oxlint.test.ts +61 -0
  49. package/src/oxlint.ts +23 -0
  50. package/src/rules/no-algorithm-confusion/index.ts +208 -0
  51. package/src/rules/no-algorithm-confusion/no-algorithm-confusion.test.ts +106 -0
  52. package/src/rules/no-algorithm-none/index.ts +270 -0
  53. package/src/rules/no-algorithm-none/no-algorithm-none.test.ts +146 -0
  54. package/src/rules/no-decode-without-verify/index.ts +162 -0
  55. package/src/rules/no-decode-without-verify/no-decode-without-verify.test.ts +76 -0
  56. package/src/rules/no-hardcoded-secret/index.ts +230 -0
  57. package/src/rules/no-hardcoded-secret/no-hardcoded-secret.test.ts +97 -0
  58. package/src/rules/no-sensitive-payload/index.ts +123 -0
  59. package/src/rules/no-sensitive-payload/no-sensitive-payload.test.ts +157 -0
  60. package/src/rules/no-timestamp-manipulation/index.ts +118 -0
  61. package/src/rules/no-timestamp-manipulation/no-timestamp-manipulation.test.ts +54 -0
  62. package/src/rules/no-weak-secret/index.ts +204 -0
  63. package/src/rules/no-weak-secret/no-weak-secret.test.ts +114 -0
  64. package/src/rules/require-algorithm-whitelist/index.ts +145 -0
  65. package/src/rules/require-algorithm-whitelist/require-algorithm-whitelist.test.ts +72 -0
  66. package/src/rules/require-audience-validation/index.ts +120 -0
  67. package/src/rules/require-audience-validation/require-audience-validation.test.ts +68 -0
  68. package/src/rules/require-expiration/index.ts +170 -0
  69. package/src/rules/require-expiration/require-expiration.test.ts +135 -0
  70. package/src/rules/require-issued-at/index.ts +143 -0
  71. package/src/rules/require-issued-at/require-issued-at.test.ts +64 -0
  72. package/src/rules/require-issuer-validation/index.ts +120 -0
  73. package/src/rules/require-issuer-validation/require-issuer-validation.test.ts +68 -0
  74. package/src/rules/require-max-age/index.ts +117 -0
  75. package/src/rules/require-max-age/require-max-age.test.ts +69 -0
  76. package/src/tests/coverage-dual-layer.test.ts +323 -0
  77. package/src/types/index.ts +107 -0
  78. package/src/utils/index.ts +285 -0
  79. package/src/utils/utils.test.ts +324 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Copyright (c) 2025 Ofri Peretz
3
+ * Licensed under the MIT License. Use of this source code is governed by the
4
+ * MIT license that can be found in the LICENSE file.
5
+ */
6
+
7
+ /**
8
+ * ESLint Rule: require-audience-validation
9
+ *
10
+ * Mandates audience (aud) claim validation in JWT verify operations.
11
+ * Without audience validation, tokens intended for other services are accepted.
12
+ *
13
+ * CWE-287: Improper Authentication
14
+ */
15
+ import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
16
+ import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
17
+ import { isVerifyOperation, getOptionsArgument, hasOption } from '../../utils';
18
+ import type { RequireAudienceValidationOptions } from '../../types';
19
+
20
+ type MessageIds = 'missingAudienceValidation';
21
+
22
+ type RuleOptions = [RequireAudienceValidationOptions?];
23
+
24
+ export const requireAudienceValidation = createRule<RuleOptions, MessageIds>({
25
+ name: 'require-audience-validation',
26
+ meta: {
27
+ type: 'problem',
28
+ docs: {
29
+ url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-audience-validation.md',
30
+ description:
31
+ 'Require audience (aud) claim validation in JWT verify operations',
32
+ cwe: 'CWE-287',
33
+ cvss: 9.8,
34
+ },
35
+ fixable: undefined,
36
+ hasSuggestions: false,
37
+ messages: {
38
+ missingAudienceValidation: formatLLMMessage({
39
+ icon: MessageIcons.SECURITY,
40
+ issueName: 'Missing Audience Validation',
41
+ cwe: 'CWE-287',
42
+ description:
43
+ 'JWT verification without audience validation accepts tokens intended for other services',
44
+ severity: 'MEDIUM',
45
+ fix: 'Add audience option: { audience: "https://api.example.com" }',
46
+ documentationLink: 'https://tools.ietf.org/html/rfc8725',
47
+ }),
48
+ },
49
+ schema: [
50
+ {
51
+ type: 'object',
52
+ properties: {
53
+ knownAudiences: {
54
+ type: 'array',
55
+ items: { type: 'string' },
56
+ default: [],
57
+ description: 'Known valid audiences to suggest',
58
+ },
59
+ trustedSanitizers: {
60
+ type: 'array',
61
+ items: { type: 'string' },
62
+ default: [],
63
+ },
64
+ trustedAnnotations: {
65
+ type: 'array',
66
+ items: { type: 'string' },
67
+ default: [],
68
+ },
69
+ strictMode: {
70
+ type: 'boolean',
71
+ default: false,
72
+ },
73
+ },
74
+ additionalProperties: false,
75
+ },
76
+ ],
77
+ },
78
+ defaultOptions: [
79
+ {
80
+ knownAudiences: [],
81
+ trustedSanitizers: [],
82
+ trustedAnnotations: [],
83
+ strictMode: false,
84
+ },
85
+ ],
86
+ create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
87
+ return {
88
+ CallExpression(node: TSESTree.CallExpression) {
89
+ if (!isVerifyOperation(node)) {
90
+ return;
91
+ }
92
+
93
+ if (node.arguments.length < 2) {
94
+ return;
95
+ }
96
+
97
+ const optionsArg = getOptionsArgument(node, 2);
98
+
99
+ // No options at all
100
+ if (!optionsArg) {
101
+ context.report({
102
+ node,
103
+ messageId: 'missingAudienceValidation',
104
+ });
105
+ return;
106
+ }
107
+
108
+ // Check for audience option
109
+ if (!hasOption(optionsArg, 'audience') && !hasOption(optionsArg, 'aud')) {
110
+ context.report({
111
+ node: optionsArg,
112
+ messageId: 'missingAudienceValidation',
113
+ });
114
+ }
115
+ },
116
+ };
117
+ },
118
+ });
119
+
120
+ export default requireAudienceValidation;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for require-audience-validation rule
3
+ */
4
+ import { RuleTester } from '@typescript-eslint/rule-tester';
5
+ import { describe, it, afterAll } from 'vitest';
6
+ import parser from '@typescript-eslint/parser';
7
+ import { requireAudienceValidation } from './index';
8
+
9
+ RuleTester.afterAll = afterAll;
10
+ RuleTester.it = it;
11
+ RuleTester.itOnly = it.only;
12
+ RuleTester.describe = describe;
13
+
14
+ const ruleTester = new RuleTester({
15
+ languageOptions: {
16
+ parser,
17
+ ecmaVersion: 2022,
18
+ sourceType: 'module',
19
+ },
20
+ });
21
+
22
+ describe('require-audience-validation', () => {
23
+ describe('Valid Code', () => {
24
+ ruleTester.run('valid - with audience', requireAudienceValidation, {
25
+ valid: [
26
+ { code: `jwt.verify(token, secret, { audience: 'https://api.example.com' });` },
27
+ { code: `jwt.verify(token, secret, { aud: 'api.example.com' });` },
28
+ { code: `jwt.verify(token, secret, { audience: ['api', 'web'] });` },
29
+ { code: `jwt.sign(payload, secret);` }, // sign not checked
30
+ // Only one argument - edge case
31
+ { code: `jwt.verify(token);` },
32
+ // jwtVerify with audience
33
+ { code: `jwtVerify(token, key, { audience: 'https://api.example.com' });` },
34
+ ],
35
+ invalid: [],
36
+ });
37
+ });
38
+
39
+ describe('Invalid Code', () => {
40
+ ruleTester.run('invalid - missing audience', requireAudienceValidation, {
41
+ valid: [],
42
+ invalid: [
43
+ {
44
+ code: `jwt.verify(token, secret);`,
45
+ errors: [{ messageId: 'missingAudienceValidation' }],
46
+ },
47
+ {
48
+ code: `jwt.verify(token, secret, {});`,
49
+ errors: [{ messageId: 'missingAudienceValidation' }],
50
+ },
51
+ {
52
+ code: `jwt.verify(token, secret, { issuer: 'auth.example.com' });`,
53
+ errors: [{ messageId: 'missingAudienceValidation' }],
54
+ },
55
+ // jwtVerify without audience
56
+ {
57
+ code: `jwtVerify(token, key);`,
58
+ errors: [{ messageId: 'missingAudienceValidation' }],
59
+ },
60
+ // jwtVerify with only issuer
61
+ {
62
+ code: `jwtVerify(token, key, { issuer: 'auth.example.com' });`,
63
+ errors: [{ messageId: 'missingAudienceValidation' }],
64
+ },
65
+ ],
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Copyright (c) 2025 Ofri Peretz
3
+ * Licensed under the MIT License. Use of this source code is governed by the
4
+ * MIT license that can be found in the LICENSE file.
5
+ */
6
+
7
+ /**
8
+ * ESLint Rule: require-expiration
9
+ *
10
+ * Mandates expiration claim (exp) or expiresIn option in JWT sign operations.
11
+ * Tokens without expiration are valid forever, creating security risks.
12
+ *
13
+ * CWE-613: Insufficient Session Expiration
14
+ *
15
+ * @see https://tools.ietf.org/html/rfc8725
16
+ */
17
+ import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
18
+ import { AST_NODE_TYPES, createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
19
+ import {
20
+ isSignOperation,
21
+ getOptionsArgument,
22
+ hasOption,
23
+ } from '../../utils';
24
+ import type { RequireExpirationOptions } from '../../types';
25
+
26
+ type MessageIds =
27
+ | 'missingExpiration'
28
+ | 'addExpiration';
29
+
30
+ type RuleOptions = [RequireExpirationOptions?];
31
+
32
+ export const requireExpiration = createRule<RuleOptions, MessageIds>({
33
+ name: 'require-expiration',
34
+ meta: {
35
+ type: 'problem',
36
+ docs: {
37
+ url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-expiration.md',
38
+ description:
39
+ 'Require expiration claim (exp) or expiresIn option in JWT signing',
40
+ cwe: 'CWE-613',
41
+ cvss: 5.4,
42
+ },
43
+ hasSuggestions: true,
44
+ messages: {
45
+ missingExpiration: formatLLMMessage({
46
+ icon: MessageIcons.SECURITY,
47
+ issueName: 'Missing JWT Expiration',
48
+ cwe: 'CWE-613',
49
+ description:
50
+ 'JWT without expiration is valid forever, increasing exposure window',
51
+ severity: 'MEDIUM',
52
+ fix: 'Add expiresIn: "1h" or exp claim to payload',
53
+ documentationLink: 'https://tools.ietf.org/html/rfc8725',
54
+ }),
55
+ addExpiration: formatLLMMessage({
56
+ icon: MessageIcons.INFO,
57
+ issueName: 'Add Expiration',
58
+ description: 'Add expiration to limit token lifetime',
59
+ severity: 'LOW',
60
+ fix: 'jwt.sign(payload, secret, { expiresIn: "1h" })',
61
+ documentationLink: 'https://www.npmjs.com/package/jsonwebtoken',
62
+ }),
63
+ },
64
+ schema: [
65
+ {
66
+ type: 'object',
67
+ properties: {
68
+ maxExpirationSeconds: {
69
+ type: 'integer',
70
+ default: 86400,
71
+ description: 'Maximum allowed expiration time in seconds (24h default)',
72
+ },
73
+ trustedSanitizers: {
74
+ type: 'array',
75
+ items: { type: 'string' },
76
+ default: [],
77
+ },
78
+ trustedAnnotations: {
79
+ type: 'array',
80
+ items: { type: 'string' },
81
+ default: [],
82
+ },
83
+ strictMode: {
84
+ type: 'boolean',
85
+ default: false,
86
+ },
87
+ },
88
+ additionalProperties: false,
89
+ },
90
+ ],
91
+ },
92
+ defaultOptions: [
93
+ {
94
+ maxExpirationSeconds: 86400,
95
+ trustedSanitizers: [],
96
+ trustedAnnotations: [],
97
+ strictMode: false,
98
+ },
99
+ ],
100
+ create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
101
+ /**
102
+ * Check if payload contains exp claim
103
+ */
104
+ // oxlint-disable-next-line consistent-function-scoping
105
+ const payloadHasExp = (payloadNode: TSESTree.Node): boolean => {
106
+ if (payloadNode.type !== 'ObjectExpression') {
107
+ return false;
108
+ }
109
+
110
+ return payloadNode.properties.some(
111
+ (prop) =>
112
+ prop.type === 'Property' &&
113
+ prop.key.type === 'Identifier' &&
114
+ prop.key.name === 'exp'
115
+ );
116
+ };
117
+
118
+ return {
119
+ CallExpression(node: TSESTree.CallExpression) {
120
+ // Only check sign operations
121
+ if (!isSignOperation(node)) {
122
+ return;
123
+ }
124
+
125
+ // Need at least payload argument
126
+ if (node.arguments.length < 1) {
127
+ return;
128
+ }
129
+
130
+ const payloadArg = node.arguments[0];
131
+ const optionsArg = getOptionsArgument(node, 2);
132
+
133
+ // Check if payload has exp claim
134
+ if (payloadHasExp(payloadArg)) {
135
+ return;
136
+ }
137
+
138
+ // Check if options has expiresIn
139
+ if (optionsArg && hasOption(optionsArg, 'expiresIn')) {
140
+ return;
141
+ }
142
+
143
+ // Report missing expiration — suggest adding expiresIn to the options object
144
+ const optionsArg3 = node.arguments[2];
145
+ context.report({
146
+ node,
147
+ messageId: 'missingExpiration',
148
+ suggest: [
149
+ {
150
+ messageId: 'addExpiration',
151
+ fix(fixer) {
152
+ const sourceCode = context.sourceCode;
153
+ if (optionsArg3 && optionsArg3.type === AST_NODE_TYPES.ObjectExpression) {
154
+ // Options object exists but lacks expiresIn — insert property
155
+ const openBrace = sourceCode.getFirstToken(optionsArg3)!;
156
+ return fixer.insertTextAfter(openBrace, ' expiresIn: \'1h\',');
157
+ }
158
+ // No options arg — insert { expiresIn: '1h' } as third argument
159
+ const lastArg = node.arguments[node.arguments.length - 1];
160
+ return fixer.insertTextAfter(lastArg, ', { expiresIn: \'1h\' }');
161
+ },
162
+ },
163
+ ],
164
+ });
165
+ },
166
+ };
167
+ },
168
+ });
169
+
170
+ export default requireExpiration;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for require-expiration rule
3
+ */
4
+ import { RuleTester } from '@typescript-eslint/rule-tester';
5
+ import { describe, it, afterAll } from 'vitest';
6
+ import parser from '@typescript-eslint/parser';
7
+ import { requireExpiration } from './index';
8
+
9
+ RuleTester.afterAll = afterAll;
10
+ RuleTester.it = it;
11
+ RuleTester.itOnly = it.only;
12
+ RuleTester.describe = describe;
13
+
14
+ const ruleTester = new RuleTester({
15
+ languageOptions: {
16
+ parser,
17
+ ecmaVersion: 2022,
18
+ sourceType: 'module',
19
+ },
20
+ });
21
+
22
+ describe('require-expiration', () => {
23
+ describe('Valid Code', () => {
24
+ ruleTester.run('valid - with expiration', requireExpiration, {
25
+ valid: [
26
+ // expiresIn option
27
+ { code: `jwt.sign(payload, secret, { expiresIn: '1h' });` },
28
+ { code: `jwt.sign(payload, secret, { expiresIn: 3600 });` },
29
+ // exp in payload
30
+ { code: `jwt.sign({ sub: 'user', exp: 1234567890 }, secret);` },
31
+ { code: `jwt.sign({ exp: Math.floor(Date.now()/1000) + 3600 }, secret);` },
32
+ // verify not checked
33
+ { code: `jwt.verify(token, secret);` },
34
+ // signJWT with expiresIn
35
+ { code: `signJWT(payload, key, { expiresIn: '1h' });` },
36
+ // Zero arguments - edge case (line 118 coverage)
37
+ { code: `jwt.sign();` },
38
+ ],
39
+ invalid: [],
40
+ });
41
+ });
42
+
43
+ describe('Invalid Code', () => {
44
+ ruleTester.run('invalid - no expiration', requireExpiration, {
45
+ valid: [],
46
+ invalid: [
47
+ {
48
+ code: `jwt.sign(payload, secret);`,
49
+ errors: [
50
+ {
51
+ messageId: 'missingExpiration',
52
+ suggestions: [
53
+ {
54
+ messageId: 'addExpiration',
55
+ output: `jwt.sign(payload, secret, { expiresIn: '1h' });`,
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ },
61
+ {
62
+ code: `jwt.sign(payload, secret, {});`,
63
+ errors: [
64
+ {
65
+ messageId: 'missingExpiration',
66
+ suggestions: [
67
+ {
68
+ messageId: 'addExpiration',
69
+ output: `jwt.sign(payload, secret, { expiresIn: '1h',});`,
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ {
76
+ code: `jwt.sign(payload, secret, { algorithm: 'RS256' });`,
77
+ errors: [
78
+ {
79
+ messageId: 'missingExpiration',
80
+ suggestions: [
81
+ {
82
+ messageId: 'addExpiration',
83
+ output: `jwt.sign(payload, secret, { expiresIn: '1h', algorithm: 'RS256' });`,
84
+ },
85
+ ],
86
+ },
87
+ ],
88
+ },
89
+ {
90
+ code: `jwt.sign({ sub: 'user', iat: Date.now() }, secret);`,
91
+ errors: [
92
+ {
93
+ messageId: 'missingExpiration',
94
+ suggestions: [
95
+ {
96
+ messageId: 'addExpiration',
97
+ output: `jwt.sign({ sub: 'user', iat: Date.now() }, secret, { expiresIn: '1h' });`,
98
+ },
99
+ ],
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ code: `sign(payload, key);`,
105
+ errors: [
106
+ {
107
+ messageId: 'missingExpiration',
108
+ suggestions: [
109
+ {
110
+ messageId: 'addExpiration',
111
+ output: `sign(payload, key, { expiresIn: '1h' });`,
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ },
117
+ // signJWT without expiration
118
+ {
119
+ code: `signJWT({ sub: 'user' }, key, { algorithm: 'RS256' });`,
120
+ errors: [
121
+ {
122
+ messageId: 'missingExpiration',
123
+ suggestions: [
124
+ {
125
+ messageId: 'addExpiration',
126
+ output: `signJWT({ sub: 'user' }, key, { expiresIn: '1h', algorithm: 'RS256' });`,
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ ],
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Copyright (c) 2025 Ofri Peretz
3
+ * Licensed under the MIT License. Use of this source code is governed by the
4
+ * MIT license that can be found in the LICENSE file.
5
+ */
6
+
7
+ /**
8
+ * ESLint Rule: require-issued-at
9
+ *
10
+ * Mandates the `iat` (issued at) claim in JWT tokens for freshness validation.
11
+ * Without `iat`, tokens cannot be validated for age, enabling the
12
+ * "Back to the Future" replay attack (LightSEC 2025).
13
+ *
14
+ * CWE-294: Authentication Bypass by Capture-replay
15
+ *
16
+ * @see https://securitypattern.com/post/jwt-back-to-the-future
17
+ */
18
+ import type { TSESLint, TSESTree } from '@interlace/eslint-devkit';
19
+ import { createRule, formatLLMMessage, MessageIcons } from '@interlace/eslint-devkit';
20
+ import { isSignOperation, getOptionsArgument, hasOption } from '../../utils';
21
+ import type { JwtRuleOptions } from '../../types';
22
+
23
+ type MessageIds = 'missingIssuedAt';
24
+
25
+ type RuleOptions = [JwtRuleOptions?];
26
+
27
+ export const requireIssuedAt = createRule<RuleOptions, MessageIds>({
28
+ name: 'require-issued-at',
29
+ meta: {
30
+ type: 'problem',
31
+ docs: {
32
+ url: 'https://github.com/ofri-peretz/eslint/blob/main/packages/eslint-plugin-jwt-security/docs/rules/require-issued-at.md',
33
+ description:
34
+ 'Require iat (issued at) claim for token freshness validation',
35
+ cwe: 'CWE-294',
36
+ cvss: 5,
37
+ },
38
+ fixable: undefined,
39
+ hasSuggestions: false,
40
+ messages: {
41
+ missingIssuedAt: formatLLMMessage({
42
+ icon: MessageIcons.SECURITY,
43
+ issueName: 'Missing Issued At Claim',
44
+ cwe: 'CWE-294',
45
+ description:
46
+ 'JWT without iat claim cannot be validated for freshness, enabling replay attacks',
47
+ severity: 'MEDIUM',
48
+ fix: 'Add iat claim to payload or use library option that adds it automatically',
49
+ documentationLink: 'https://securitypattern.com/post/jwt-back-to-the-future',
50
+ }),
51
+ },
52
+ schema: [
53
+ {
54
+ type: 'object',
55
+ properties: {
56
+ trustedSanitizers: {
57
+ type: 'array',
58
+ items: { type: 'string' },
59
+ default: [],
60
+ },
61
+ trustedAnnotations: {
62
+ type: 'array',
63
+ items: { type: 'string' },
64
+ default: [],
65
+ },
66
+ strictMode: {
67
+ type: 'boolean',
68
+ default: false,
69
+ },
70
+ },
71
+ additionalProperties: false,
72
+ },
73
+ ],
74
+ },
75
+ defaultOptions: [
76
+ {
77
+ trustedSanitizers: [],
78
+ trustedAnnotations: [],
79
+ strictMode: false,
80
+ },
81
+ ],
82
+ create(context: TSESLint.RuleContext<MessageIds, RuleOptions>) {
83
+ /**
84
+ * Check if payload contains iat claim
85
+ */
86
+ // oxlint-disable-next-line consistent-function-scoping
87
+ const payloadHasIat = (payloadNode: TSESTree.Node): boolean => {
88
+ if (payloadNode.type !== 'ObjectExpression') {
89
+ return false;
90
+ }
91
+
92
+ return payloadNode.properties.some(
93
+ (prop) =>
94
+ prop.type === 'Property' &&
95
+ prop.key.type === 'Identifier' &&
96
+ prop.key.name === 'iat'
97
+ );
98
+ };
99
+
100
+ return {
101
+ CallExpression(node: TSESTree.CallExpression) {
102
+ // Only check sign operations
103
+ if (!isSignOperation(node)) {
104
+ return;
105
+ }
106
+
107
+ // Need at least payload argument
108
+ if (node.arguments.length < 1) {
109
+ return;
110
+ }
111
+
112
+ const payloadArg = node.arguments[0];
113
+ const optionsArg = getOptionsArgument(node, 2);
114
+
115
+ // Check if payload has iat claim
116
+ if (payloadHasIat(payloadArg)) {
117
+ return;
118
+ }
119
+
120
+ // Check if options has noTimestamp: false (meaning iat IS added)
121
+ // jsonwebtoken adds iat by default unless noTimestamp: true
122
+ if (optionsArg && hasOption(optionsArg, 'noTimestamp')) {
123
+ // If noTimestamp is explicitly set, we flag it (they're disabling iat)
124
+ context.report({
125
+ node: optionsArg,
126
+ messageId: 'missingIssuedAt',
127
+ });
128
+ return;
129
+ }
130
+
131
+ // For explicit payload objects without iat we intentionally do not
132
+ // report — jsonwebtoken adds iat by default. (A dead empty `if` that
133
+ // re-checked !payloadHasIat(payloadArg) — always true after the early
134
+ // return above — was removed as unreachable-by-construction.)
135
+
136
+ // Note: jsonwebtoken adds iat by default, so we don't flag by default
137
+ // This rule primarily catches explicit noTimestamp: true usage
138
+ },
139
+ };
140
+ },
141
+ });
142
+
143
+ export default requireIssuedAt;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Tests for require-issued-at rule
3
+ */
4
+ import { RuleTester } from '@typescript-eslint/rule-tester';
5
+ import { describe, it, afterAll } from 'vitest';
6
+ import parser from '@typescript-eslint/parser';
7
+ import { requireIssuedAt } from './index';
8
+
9
+ RuleTester.afterAll = afterAll;
10
+ RuleTester.it = it;
11
+ RuleTester.itOnly = it.only;
12
+ RuleTester.describe = describe;
13
+
14
+ const ruleTester = new RuleTester({
15
+ languageOptions: {
16
+ parser,
17
+ ecmaVersion: 2022,
18
+ sourceType: 'module',
19
+ },
20
+ });
21
+
22
+ describe('require-issued-at', () => {
23
+ describe('Valid Code', () => {
24
+ ruleTester.run('valid - with iat', requireIssuedAt, {
25
+ valid: [
26
+ // iat in payload
27
+ { code: `jwt.sign({ sub: 'user', iat: Date.now() }, secret);` },
28
+ // Default behavior (jsonwebtoken adds iat automatically)
29
+ { code: `jwt.sign(payload, secret);` },
30
+ { code: `jwt.sign(payload, secret, { expiresIn: '1h' });` },
31
+ // Verify not checked
32
+ { code: `jwt.verify(token, secret);` },
33
+ // No arguments - edge case
34
+ { code: `sign();` },
35
+ // Variable payload with iat
36
+ { code: `jwt.sign({ iat: Math.floor(Date.now() / 1000), sub: 'user' }, secret);` },
37
+ ],
38
+ invalid: [],
39
+ });
40
+ });
41
+
42
+ describe('Invalid Code', () => {
43
+ ruleTester.run('invalid - noTimestamp disables iat', requireIssuedAt, {
44
+ valid: [],
45
+ invalid: [
46
+ // noTimestamp: true explicitly disables iat
47
+ {
48
+ code: `jwt.sign({ sub: 'user' }, secret, { noTimestamp: true });`,
49
+ errors: [{ messageId: 'missingIssuedAt' }],
50
+ },
51
+ // noTimestamp with other options
52
+ {
53
+ code: `jwt.sign(payload, secret, { expiresIn: '1h', noTimestamp: true });`,
54
+ errors: [{ messageId: 'missingIssuedAt' }],
55
+ },
56
+ // SignJWT with noTimestamp
57
+ {
58
+ code: `signJWT(payload, key, { noTimestamp: true });`,
59
+ errors: [{ messageId: 'missingIssuedAt' }],
60
+ },
61
+ ],
62
+ });
63
+ });
64
+ });