@techiev2/vajra 1.4.0 → 1.4.1
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/README +4 -1
- package/README.md +4 -1
- package/libs/auth/jwt.js +4 -2
- package/package.json +1 -1
- package/tests/tests.js +117 -11
package/README
CHANGED
|
@@ -22,7 +22,10 @@ Like the Vajra, this server delivers maximum power in minimal form.
|
|
|
22
22
|
|
|
23
23
|
## Changelog
|
|
24
24
|
|
|
25
|
-
### 1.4.
|
|
25
|
+
### 1.4.1 (Current)
|
|
26
|
+
- Added support to handle drift in system time after signing
|
|
27
|
+
|
|
28
|
+
### 1.4.0 (2025-12-31)
|
|
26
29
|
- Added full HS256 JWT support (`@techiev2/vajra/libs/auth/jwt.js`)
|
|
27
30
|
- Ultra-minimal, zero-dependency implementation
|
|
28
31
|
- Key and header caching for maximum performance
|
package/README.md
CHANGED
|
@@ -22,7 +22,10 @@ Like the Vajra, this server delivers maximum power in minimal form.
|
|
|
22
22
|
|
|
23
23
|
## Changelog
|
|
24
24
|
|
|
25
|
-
### 1.4.
|
|
25
|
+
### 1.4.1 (Current)
|
|
26
|
+
- Added support to handle drift in system time after signing
|
|
27
|
+
|
|
28
|
+
### 1.4.0 (2025-12-31)
|
|
26
29
|
- Added full HS256 JWT support (`@techiev2/vajra/libs/auth/jwt.js`)
|
|
27
30
|
- Ultra-minimal, zero-dependency implementation
|
|
28
31
|
- Key and header caching for maximum performance
|
package/libs/auth/jwt.js
CHANGED
|
@@ -11,7 +11,7 @@ async function populateKeyCache(secret) {
|
|
|
11
11
|
export async function sign(payload, secret, options = {}) {
|
|
12
12
|
if (!payload || typeof payload !== 'object') throw new Error("Payload to sign must be an object"); if (typeof secret !== 'string') throw new Error("Secret must be a string")
|
|
13
13
|
const { signKey } = await populateKeyCache(secret); const { alg = 'HS256' } = options;
|
|
14
|
-
ENCODED_HEADERS[alg] = ENCODED_HEADERS[alg] || DATA.encoder.encode({ ...DATA.headers.sign, alg }); const encodedPayload = encode(DATA.encoder.encode(JSON.stringify(payload)))
|
|
14
|
+
ENCODED_HEADERS[alg] = ENCODED_HEADERS[alg] || DATA.encoder.encode({ ...DATA.headers.sign, alg }); const encodedPayload = encode(DATA.encoder.encode(JSON.stringify({ ...payload, _gen_ts: Math.floor(Date.now() / 1000) })))
|
|
15
15
|
const encodedSignature = encode(await subtle.sign('HMAC', signKey, DATA.encoder.encode(`${ENCODED_HEADERS[alg]}.${encodedPayload}`))); return `${ENCODED_HEADERS[alg]}.${encodedPayload}.${encodedSignature}`;
|
|
16
16
|
}
|
|
17
17
|
export async function verify(token, secret) {
|
|
@@ -19,5 +19,7 @@ export async function verify(token, secret) {
|
|
|
19
19
|
const [encodedHeader, encodedPayload, encodedSignature, ..._] = token.split('.'); if (!encodedPayload || !encodedSignature || _.length) throw new Error('Invalid token format');
|
|
20
20
|
const { verifyKey } = await populateKeyCache(secret); const valid = await subtle.verify('HMAC', verifyKey, decode(encodedSignature), DATA.encoder.encode(`${encodedHeader}.${encodedPayload}`));
|
|
21
21
|
if (!valid) throw new Error('Invalid signature'); const payload = JSON.parse(DATA.decoder.decode(decode(encodedPayload)));
|
|
22
|
-
|
|
22
|
+
const genTs = payload._gen_ts; const now = Math.floor(Date.now() / 1000); const maxBackwardDrift = 300;
|
|
23
|
+
if (typeof genTs === 'number' && (now + maxBackwardDrift < genTs)) { throw new Error('System clock appears to have moved backward — token rejected'); }
|
|
24
|
+
delete payload._gen_ts; if (payload.exp && isNaN(+payload.exp)) throw new Error("Expiry must be numeric."); if (payload.exp && Date.now() >= payload.exp * 1000) { throw new Error('Token expired'); } return payload;
|
|
23
25
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techiev2/vajra",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Blazing-fast, zero-dependency Node.js server with routing, middleware, multipart uploads, and templating. 111 lines · ~95k req/s · ~52 MB idle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"http-server",
|
package/tests/tests.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { encode } from 'node:querystring';
|
|
3
|
-
import { suite, test } from 'node:test';
|
|
3
|
+
import { afterEach, beforeEach, suite, test } from 'node:test';
|
|
4
4
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
5
5
|
|
|
6
6
|
import pkg from '../package.json' with {type: 'json'}
|
|
@@ -17,6 +17,7 @@ async function getJSON(url, method = 'GET', body) {
|
|
|
17
17
|
return (await getResponse(url, method, body)).json()
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
|
|
20
21
|
suite('Test HTTP API at port 4002', () => {
|
|
21
22
|
const BASE_URL = 'http://localhost:4002'
|
|
22
23
|
suite('Test HTTP GET', () => {
|
|
@@ -64,7 +65,18 @@ suite('Test HTTP API at port 4002', () => {
|
|
|
64
65
|
})
|
|
65
66
|
|
|
66
67
|
suite('Tests for library functions', () => {
|
|
67
|
-
const encoder = new TextEncoder(); const signHeaders = { alg: 'HS256', typ: 'JWT' }
|
|
68
|
+
const encoder = new TextEncoder(); const signHeaders = { alg: 'HS256', typ: 'JWT' }; let timeOffsetMs = 0;
|
|
69
|
+
// const originalDateNow = Date.now;
|
|
70
|
+
// Date.now = function () { return originalDateNow() + timeOffsetMs; };
|
|
71
|
+
function setupMockTimer(timing) {
|
|
72
|
+
const originalNow = globalThis.Date.now
|
|
73
|
+
let timeDelta = timing
|
|
74
|
+
globalThis.Date.now = () => { return originalNow() + (timeDelta || 0) }
|
|
75
|
+
}
|
|
76
|
+
async function withMockTimer(timeDelta, fn) {
|
|
77
|
+
setupMockTimer(timeDelta)
|
|
78
|
+
await fn()
|
|
79
|
+
}
|
|
68
80
|
test('Verify that JWT helper returns the right token', async () => {
|
|
69
81
|
const secret = randomBytes(16).toString('hex')
|
|
70
82
|
const data = {
|
|
@@ -99,7 +111,7 @@ suite('Tests for library functions', () => {
|
|
|
99
111
|
await assert.rejects(verify('a..c', secret),);
|
|
100
112
|
await assert.rejects(verify('a.b.', secret),);
|
|
101
113
|
});
|
|
102
|
-
|
|
114
|
+
|
|
103
115
|
test('rejects tampered signature', async () => {
|
|
104
116
|
const token = await sign({a:1}, 'secret');
|
|
105
117
|
const tampered = token.slice(0, -1) + (token.at(-1) === 'A' ? 'B' : 'A');
|
|
@@ -120,7 +132,7 @@ suite('Tests for library functions', () => {
|
|
|
120
132
|
const verified = await verify(token, secret);
|
|
121
133
|
assert.deepStrictEqual(verified, payload);
|
|
122
134
|
});
|
|
123
|
-
|
|
135
|
+
|
|
124
136
|
test('rejects token with future exp after clock advances', async () => {
|
|
125
137
|
const secret = 's';
|
|
126
138
|
const past = Math.floor(Date.now() / 1000) - 3600;
|
|
@@ -128,7 +140,7 @@ suite('Tests for library functions', () => {
|
|
|
128
140
|
const token = await sign(payload, secret);
|
|
129
141
|
await assert.rejects(verify(token, secret), /Token expired/);
|
|
130
142
|
});
|
|
131
|
-
|
|
143
|
+
|
|
132
144
|
test('handles large payload without truncation', async () => {
|
|
133
145
|
const secret = 's';
|
|
134
146
|
const large = { data: 'x'.repeat(5000), arr: Array(100).fill(42) };
|
|
@@ -144,13 +156,13 @@ suite('Tests for library functions', () => {
|
|
|
144
156
|
const badToken = `${header}.${badPayload}.${fakeSig}`;
|
|
145
157
|
await assert.rejects(verify(badToken, 'secret'), /Invalid signature/);
|
|
146
158
|
});
|
|
147
|
-
|
|
159
|
+
|
|
148
160
|
test('rejects non-object payload on sign', async () => {
|
|
149
161
|
await assert.rejects(sign('string', 'secret'));
|
|
150
162
|
await assert.rejects(sign(null, 'secret'));
|
|
151
163
|
await assert.rejects(sign(123, 'secret'));
|
|
152
164
|
});
|
|
153
|
-
|
|
165
|
+
|
|
154
166
|
// Add to existing suite
|
|
155
167
|
test('rejects non-numeric exp claim', async () => {
|
|
156
168
|
const secret = 's';
|
|
@@ -158,7 +170,7 @@ suite('Tests for library functions', () => {
|
|
|
158
170
|
const token = await sign(payload, secret);
|
|
159
171
|
await assert.rejects(verify(token, secret), /Expiry must be numeric/);
|
|
160
172
|
});
|
|
161
|
-
|
|
173
|
+
|
|
162
174
|
test('handles clock skew edge case', async () => {
|
|
163
175
|
const secret = 's';
|
|
164
176
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -167,7 +179,7 @@ suite('Tests for library functions', () => {
|
|
|
167
179
|
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
|
|
168
180
|
await assert.rejects(verify(token, secret), /Token expired/);
|
|
169
181
|
});
|
|
170
|
-
|
|
182
|
+
|
|
171
183
|
test('rejects malformed base64url segments', async () => {
|
|
172
184
|
const secret = 's';
|
|
173
185
|
const header = encode(encoder.encode(JSON.stringify(signHeaders)));
|
|
@@ -176,7 +188,7 @@ suite('Tests for library functions', () => {
|
|
|
176
188
|
await assert.rejects(verify(`${header}.${badSegment}.AAAA`, secret), /Invalid signature/);
|
|
177
189
|
await assert.rejects(verify(`${header}.${validPayload}.!!invalid@@`, secret), /Invalid signature/);
|
|
178
190
|
});
|
|
179
|
-
|
|
191
|
+
|
|
180
192
|
test('handles empty payload object', async () => {
|
|
181
193
|
const secret = 's';
|
|
182
194
|
const payload = {};
|
|
@@ -184,7 +196,7 @@ suite('Tests for library functions', () => {
|
|
|
184
196
|
const verified = await verify(token, secret);
|
|
185
197
|
assert.deepStrictEqual(verified, payload);
|
|
186
198
|
});
|
|
187
|
-
|
|
199
|
+
|
|
188
200
|
test('rejects very weak secret', async () => {
|
|
189
201
|
const secret = 'a'; // Short but still works (crypto allows it)
|
|
190
202
|
const payload = { sub: '123' };
|
|
@@ -192,5 +204,99 @@ suite('Tests for library functions', () => {
|
|
|
192
204
|
const verified = await verify(token, secret);
|
|
193
205
|
assert.deepStrictEqual(verified, payload);
|
|
194
206
|
});
|
|
207
|
+
|
|
208
|
+
test('clock drift: expired token should NOT become valid when clock moves backward', async () => {
|
|
209
|
+
const secret = randomBytes(16).toString('hex');
|
|
210
|
+
const issueTime = Date.now();
|
|
211
|
+
const payload = { sub: 'test-user', exp: Math.floor((issueTime + 5000) / 1000) }; // Set expiry of 5 seconds
|
|
212
|
+
const token = await sign(payload, secret);
|
|
213
|
+
setupMockTimer(10000)
|
|
214
|
+
await assert.rejects(verify(token, secret), /Token expired/);
|
|
215
|
+
setupMockTimer(-3000000)
|
|
216
|
+
await assert.rejects(verify(token, secret), /System clock appears to have moved backward/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('clock drift: small backward drift does not falsely reject', async () => {
|
|
220
|
+
const secret = randomBytes(16).toString('hex');
|
|
221
|
+
const issueTime = Date.now();
|
|
222
|
+
const payload = { sub: 'test', exp: Math.floor((issueTime + 3600000) / 1000) };
|
|
223
|
+
const token = await sign(payload, secret);
|
|
224
|
+
setupMockTimer(-120000)
|
|
225
|
+
const verified = await verify(token, secret);
|
|
226
|
+
assert.strictEqual(verified.sub, 'test');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('clock drift: small backward drift within leeway should be tolerated', async () => {
|
|
230
|
+
const secret = randomBytes(16).toString('hex');
|
|
231
|
+
const issueTime = Date.now();
|
|
232
|
+
const payload = { exp: Math.floor((issueTime + 5000) / 1000) };
|
|
233
|
+
const token = await sign(payload, secret);
|
|
234
|
+
setupMockTimer(-30000)
|
|
235
|
+
assert.deepStrictEqual(await verify(token, secret), payload);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('clock drift: large backward jump should reject even non-expired token', async () => {
|
|
239
|
+
const secret = randomBytes(16).toString('hex');
|
|
240
|
+
const issueTime = Date.now();
|
|
241
|
+
const payload = { sub: 'drift-test', exp: Math.floor((issueTime + 3600000) / 1000) };
|
|
242
|
+
const token = await sign(payload, secret);
|
|
243
|
+
setupMockTimer(1000)
|
|
244
|
+
const verified = await verify(token, secret);
|
|
245
|
+
assert.strictEqual(verified.sub, 'drift-test');
|
|
246
|
+
setupMockTimer(-8640000);
|
|
247
|
+
await assert.rejects(
|
|
248
|
+
verify(token, secret),
|
|
249
|
+
/System clock appears to have moved backward — token rejected/
|
|
250
|
+
);
|
|
251
|
+
});
|
|
195
252
|
|
|
253
|
+
test('clock drift: forward jump should not falsely expire valid token', async () => {
|
|
254
|
+
const secret = randomBytes(16).toString('hex');
|
|
255
|
+
const issueTime = Date.now();
|
|
256
|
+
const payload = { exp: Math.floor((issueTime + 3600000) / 1000) }; // 1 hour valid
|
|
257
|
+
const token = await sign(payload, secret);
|
|
258
|
+
// Jump forward 30 minutes — should still be valid
|
|
259
|
+
setupMockTimer(1800000)
|
|
260
|
+
const verified = await verify(token, secret);
|
|
261
|
+
assert.ok(verified);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('clock drift: large rollback rejects all tokens regardless of individual exp', async () => {
|
|
265
|
+
const secret = randomBytes(16).toString('hex');
|
|
266
|
+
const baseTime = Date.now();
|
|
267
|
+
const payloads = [
|
|
268
|
+
{ id: 'expired', exp: Math.floor((baseTime + 5000) / 1000) },
|
|
269
|
+
{ id: 'valid', exp: Math.floor((baseTime + 3600000) / 1000) }
|
|
270
|
+
];
|
|
271
|
+
const [expiredToken, validToken] = await Promise.all([
|
|
272
|
+
sign(payloads[0], secret),
|
|
273
|
+
sign(payloads[1], secret)
|
|
274
|
+
]);
|
|
275
|
+
await withMockTimer(-720000, async () => {
|
|
276
|
+
await Promise.all([
|
|
277
|
+
assert.rejects(verify(expiredToken, secret), /System clock appears to have moved backward/),
|
|
278
|
+
assert.rejects(verify(validToken, secret), /System clock appears to have moved backward/)
|
|
279
|
+
])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('clock drift: large rollback rejects all tokens regardless of individual exp', async () => {
|
|
284
|
+
const secret = randomBytes(16).toString('hex');
|
|
285
|
+
const baseTime = Date.now();
|
|
286
|
+
const payloads = [
|
|
287
|
+
{ id: 'expired', exp: Math.floor((baseTime + 5000) / 1000) },
|
|
288
|
+
{ id: 'valid', exp: Math.floor((baseTime + 3600000) / 1000) }
|
|
289
|
+
];
|
|
290
|
+
const [expiredToken, validToken] = await Promise.all([
|
|
291
|
+
sign(payloads[0], secret),
|
|
292
|
+
sign(payloads[1], secret)
|
|
293
|
+
]);
|
|
294
|
+
await withMockTimer(-1000000, async () => {
|
|
295
|
+
await Promise.all([
|
|
296
|
+
assert.rejects(verify(expiredToken, secret), /System clock appears to have moved backward/),
|
|
297
|
+
assert.rejects(verify(validToken, secret), /System clock appears to have moved backward/)
|
|
298
|
+
])
|
|
299
|
+
})
|
|
300
|
+
});
|
|
301
|
+
|
|
196
302
|
})
|