@xivdyetools/auth 1.0.2 → 1.0.3
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.md +171 -158
- package/dist/hmac.d.ts.map +1 -1
- package/dist/hmac.js +4 -3
- package/dist/hmac.js.map +1 -1
- package/dist/jwt.js.map +1 -1
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +4 -2
- package/dist/timing.js.map +1 -1
- package/package.json +75 -71
- package/src/discord.test.ts +243 -243
- package/src/discord.ts +143 -143
- package/src/hmac.test.ts +325 -325
- package/src/hmac.ts +223 -222
- package/src/index.ts +54 -54
- package/src/jwt.test.ts +337 -337
- package/src/jwt.ts +265 -265
- package/src/timing.test.ts +114 -117
- package/src/timing.ts +86 -84
package/src/jwt.test.ts
CHANGED
|
@@ -1,337 +1,337 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for JWT Verification Utilities
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
-
import {
|
|
6
|
-
decodeJWT,
|
|
7
|
-
verifyJWT,
|
|
8
|
-
verifyJWTSignatureOnly,
|
|
9
|
-
isJWTExpired,
|
|
10
|
-
getJWTTimeToExpiry,
|
|
11
|
-
type JWTPayload,
|
|
12
|
-
} from './jwt.js';
|
|
13
|
-
import { base64UrlEncode, base64UrlEncodeBytes } from '@xivdyetools/crypto';
|
|
14
|
-
import { createHmacKey } from './hmac.js';
|
|
15
|
-
|
|
16
|
-
// Helper to create a valid JWT for testing
|
|
17
|
-
async function createTestJWT(
|
|
18
|
-
payload: JWTPayload,
|
|
19
|
-
secret: string,
|
|
20
|
-
algorithm = 'HS256'
|
|
21
|
-
): Promise<string> {
|
|
22
|
-
const header = { alg: algorithm, typ: 'JWT' };
|
|
23
|
-
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
24
|
-
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
25
|
-
|
|
26
|
-
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
27
|
-
const key = await createHmacKey(secret, 'sign');
|
|
28
|
-
const signature = await crypto.subtle.sign(
|
|
29
|
-
'HMAC',
|
|
30
|
-
key,
|
|
31
|
-
new TextEncoder().encode(signatureInput)
|
|
32
|
-
);
|
|
33
|
-
const signatureB64 = base64UrlEncodeBytes(new Uint8Array(signature));
|
|
34
|
-
|
|
35
|
-
return `${headerB64}.${payloadB64}.${signatureB64}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('jwt.ts', () => {
|
|
39
|
-
const secret = 'test-jwt-secret-key-123';
|
|
40
|
-
|
|
41
|
-
beforeEach(() => {
|
|
42
|
-
vi.useFakeTimers();
|
|
43
|
-
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
vi.useRealTimers();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('decodeJWT', () => {
|
|
51
|
-
it('should decode a valid JWT payload', async () => {
|
|
52
|
-
const payload: JWTPayload = {
|
|
53
|
-
sub: '123456789',
|
|
54
|
-
iat: Math.floor(Date.now() / 1000),
|
|
55
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
56
|
-
type: 'access',
|
|
57
|
-
username: 'testuser',
|
|
58
|
-
};
|
|
59
|
-
const token = await createTestJWT(payload, secret);
|
|
60
|
-
|
|
61
|
-
const decoded = decodeJWT(token);
|
|
62
|
-
|
|
63
|
-
expect(decoded).not.toBeNull();
|
|
64
|
-
expect(decoded?.sub).toBe('123456789');
|
|
65
|
-
expect(decoded?.type).toBe('access');
|
|
66
|
-
expect(decoded?.username).toBe('testuser');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('should return null for invalid token format', () => {
|
|
70
|
-
const decoded = decodeJWT('not.a.valid.token.format');
|
|
71
|
-
expect(decoded).toBeNull();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('should return null for malformed base64', () => {
|
|
75
|
-
const decoded = decodeJWT('not-base64.also-not.valid');
|
|
76
|
-
expect(decoded).toBeNull();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('should return null for invalid JSON payload', () => {
|
|
80
|
-
const headerB64 = base64UrlEncode('{"alg":"HS256","typ":"JWT"}');
|
|
81
|
-
const payloadB64 = base64UrlEncodeBytes(
|
|
82
|
-
new TextEncoder().encode('not-json')
|
|
83
|
-
);
|
|
84
|
-
const decoded = decodeJWT(`${headerB64}.${payloadB64}.signature`);
|
|
85
|
-
expect(decoded).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('verifyJWT', () => {
|
|
90
|
-
it('should verify a valid token', async () => {
|
|
91
|
-
const payload: JWTPayload = {
|
|
92
|
-
sub: '123456789',
|
|
93
|
-
iat: Math.floor(Date.now() / 1000),
|
|
94
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
95
|
-
type: 'access',
|
|
96
|
-
};
|
|
97
|
-
const token = await createTestJWT(payload, secret);
|
|
98
|
-
|
|
99
|
-
const verified = await verifyJWT(token, secret);
|
|
100
|
-
|
|
101
|
-
expect(verified).not.toBeNull();
|
|
102
|
-
expect(verified?.sub).toBe('123456789');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('should return null for expired token', async () => {
|
|
106
|
-
const payload: JWTPayload = {
|
|
107
|
-
sub: '123456789',
|
|
108
|
-
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
109
|
-
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
|
110
|
-
type: 'access',
|
|
111
|
-
};
|
|
112
|
-
const token = await createTestJWT(payload, secret);
|
|
113
|
-
|
|
114
|
-
const verified = await verifyJWT(token, secret);
|
|
115
|
-
|
|
116
|
-
expect(verified).toBeNull();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('should return null for wrong secret', async () => {
|
|
120
|
-
const payload: JWTPayload = {
|
|
121
|
-
sub: '123456789',
|
|
122
|
-
iat: Math.floor(Date.now() / 1000),
|
|
123
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
124
|
-
type: 'access',
|
|
125
|
-
};
|
|
126
|
-
const token = await createTestJWT(payload, secret);
|
|
127
|
-
|
|
128
|
-
const verified = await verifyJWT(token, 'wrong-secret');
|
|
129
|
-
|
|
130
|
-
expect(verified).toBeNull();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('should return null for tampered payload', async () => {
|
|
134
|
-
const payload: JWTPayload = {
|
|
135
|
-
sub: '123456789',
|
|
136
|
-
iat: Math.floor(Date.now() / 1000),
|
|
137
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
138
|
-
type: 'access',
|
|
139
|
-
};
|
|
140
|
-
const token = await createTestJWT(payload, secret);
|
|
141
|
-
|
|
142
|
-
// Tamper with the payload
|
|
143
|
-
const parts = token.split('.');
|
|
144
|
-
const tamperedPayload = { ...payload, sub: 'tampered-id' };
|
|
145
|
-
parts[1] = base64UrlEncode(JSON.stringify(tamperedPayload));
|
|
146
|
-
const tamperedToken = parts.join('.');
|
|
147
|
-
|
|
148
|
-
const verified = await verifyJWT(tamperedToken, secret);
|
|
149
|
-
|
|
150
|
-
expect(verified).toBeNull();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('should reject non-HS256 algorithm (security)', async () => {
|
|
154
|
-
const payload: JWTPayload = {
|
|
155
|
-
sub: '123456789',
|
|
156
|
-
iat: Math.floor(Date.now() / 1000),
|
|
157
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
158
|
-
type: 'access',
|
|
159
|
-
};
|
|
160
|
-
// Create token with different algorithm in header
|
|
161
|
-
const token = await createTestJWT(payload, secret, 'none');
|
|
162
|
-
|
|
163
|
-
const verified = await verifyJWT(token, secret);
|
|
164
|
-
|
|
165
|
-
expect(verified).toBeNull();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('should return null for malformed token', async () => {
|
|
169
|
-
const verified = await verifyJWT('not-a-jwt', secret);
|
|
170
|
-
expect(verified).toBeNull();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('should handle token without exp claim', async () => {
|
|
174
|
-
// Create a token without exp - should still work if signature is valid
|
|
175
|
-
const header = { alg: 'HS256', typ: 'JWT' };
|
|
176
|
-
const payload = {
|
|
177
|
-
sub: '123456789',
|
|
178
|
-
iat: Math.floor(Date.now() / 1000),
|
|
179
|
-
type: 'access',
|
|
180
|
-
};
|
|
181
|
-
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
182
|
-
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
183
|
-
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
184
|
-
const key = await createHmacKey(secret, 'sign');
|
|
185
|
-
const signature = await crypto.subtle.sign(
|
|
186
|
-
'HMAC',
|
|
187
|
-
key,
|
|
188
|
-
new TextEncoder().encode(signatureInput)
|
|
189
|
-
);
|
|
190
|
-
const signatureB64 = base64UrlEncodeBytes(new Uint8Array(signature));
|
|
191
|
-
const token = `${headerB64}.${payloadB64}.${signatureB64}`;
|
|
192
|
-
|
|
193
|
-
const verified = await verifyJWT(token, secret);
|
|
194
|
-
|
|
195
|
-
// Should pass since no exp means no expiration check
|
|
196
|
-
expect(verified).not.toBeNull();
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
describe('verifyJWTSignatureOnly', () => {
|
|
201
|
-
it('should verify signature even for expired token', async () => {
|
|
202
|
-
const payload: JWTPayload = {
|
|
203
|
-
sub: '123456789',
|
|
204
|
-
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
205
|
-
exp: Math.floor(Date.now() / 1000) - 3600, // Expired
|
|
206
|
-
type: 'refresh',
|
|
207
|
-
};
|
|
208
|
-
const token = await createTestJWT(payload, secret);
|
|
209
|
-
|
|
210
|
-
const verified = await verifyJWTSignatureOnly(token, secret);
|
|
211
|
-
|
|
212
|
-
expect(verified).not.toBeNull();
|
|
213
|
-
expect(verified?.sub).toBe('123456789');
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('should return null for invalid signature', async () => {
|
|
217
|
-
const payload: JWTPayload = {
|
|
218
|
-
sub: '123456789',
|
|
219
|
-
iat: Math.floor(Date.now() / 1000),
|
|
220
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
221
|
-
type: 'refresh',
|
|
222
|
-
};
|
|
223
|
-
const token = await createTestJWT(payload, secret);
|
|
224
|
-
|
|
225
|
-
const verified = await verifyJWTSignatureOnly(token, 'wrong-secret');
|
|
226
|
-
|
|
227
|
-
expect(verified).toBeNull();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it('should reject non-HS256 algorithm (security)', async () => {
|
|
231
|
-
const payload: JWTPayload = {
|
|
232
|
-
sub: '123456789',
|
|
233
|
-
iat: Math.floor(Date.now() / 1000),
|
|
234
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
235
|
-
type: 'refresh',
|
|
236
|
-
};
|
|
237
|
-
const token = await createTestJWT(payload, secret, 'HS384');
|
|
238
|
-
|
|
239
|
-
const verified = await verifyJWTSignatureOnly(token, secret);
|
|
240
|
-
|
|
241
|
-
expect(verified).toBeNull();
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('should respect maxAgeMs parameter', async () => {
|
|
245
|
-
const payload: JWTPayload = {
|
|
246
|
-
sub: '123456789',
|
|
247
|
-
iat: Math.floor(Date.now() / 1000) - 7200, // 2 hours ago
|
|
248
|
-
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
249
|
-
type: 'refresh',
|
|
250
|
-
};
|
|
251
|
-
const token = await createTestJWT(payload, secret);
|
|
252
|
-
|
|
253
|
-
// Should fail with 1 hour max age
|
|
254
|
-
const verified = await verifyJWTSignatureOnly(token, secret, 3600 * 1000);
|
|
255
|
-
|
|
256
|
-
expect(verified).toBeNull();
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('should accept token within maxAgeMs', async () => {
|
|
260
|
-
const payload: JWTPayload = {
|
|
261
|
-
sub: '123456789',
|
|
262
|
-
iat: Math.floor(Date.now() / 1000) - 1800, // 30 minutes ago
|
|
263
|
-
exp: Math.floor(Date.now() / 1000) - 900, // Expired 15 minutes ago
|
|
264
|
-
type: 'refresh',
|
|
265
|
-
};
|
|
266
|
-
const token = await createTestJWT(payload, secret);
|
|
267
|
-
|
|
268
|
-
// Should pass with 1 hour max age
|
|
269
|
-
const verified = await verifyJWTSignatureOnly(token, secret, 3600 * 1000);
|
|
270
|
-
|
|
271
|
-
expect(verified).not.toBeNull();
|
|
272
|
-
});
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
describe('isJWTExpired', () => {
|
|
276
|
-
it('should return false for valid non-expired token', async () => {
|
|
277
|
-
const payload: JWTPayload = {
|
|
278
|
-
sub: '123456789',
|
|
279
|
-
iat: Math.floor(Date.now() / 1000),
|
|
280
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
281
|
-
type: 'access',
|
|
282
|
-
};
|
|
283
|
-
const token = await createTestJWT(payload, secret);
|
|
284
|
-
|
|
285
|
-
expect(isJWTExpired(token)).toBe(false);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('should return true for expired token', async () => {
|
|
289
|
-
const payload: JWTPayload = {
|
|
290
|
-
sub: '123456789',
|
|
291
|
-
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
292
|
-
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
293
|
-
type: 'access',
|
|
294
|
-
};
|
|
295
|
-
const token = await createTestJWT(payload, secret);
|
|
296
|
-
|
|
297
|
-
expect(isJWTExpired(token)).toBe(true);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('should return true for malformed token', () => {
|
|
301
|
-
expect(isJWTExpired('not-a-jwt')).toBe(true);
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
describe('getJWTTimeToExpiry', () => {
|
|
306
|
-
it('should return correct time to expiry', async () => {
|
|
307
|
-
const expiresIn = 3600; // 1 hour
|
|
308
|
-
const payload: JWTPayload = {
|
|
309
|
-
sub: '123456789',
|
|
310
|
-
iat: Math.floor(Date.now() / 1000),
|
|
311
|
-
exp: Math.floor(Date.now() / 1000) + expiresIn,
|
|
312
|
-
type: 'access',
|
|
313
|
-
};
|
|
314
|
-
const token = await createTestJWT(payload, secret);
|
|
315
|
-
|
|
316
|
-
const ttl = getJWTTimeToExpiry(token);
|
|
317
|
-
|
|
318
|
-
expect(ttl).toBe(expiresIn);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
it('should return 0 for expired token', async () => {
|
|
322
|
-
const payload: JWTPayload = {
|
|
323
|
-
sub: '123456789',
|
|
324
|
-
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
325
|
-
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
326
|
-
type: 'access',
|
|
327
|
-
};
|
|
328
|
-
const token = await createTestJWT(payload, secret);
|
|
329
|
-
|
|
330
|
-
expect(getJWTTimeToExpiry(token)).toBe(0);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('should return 0 for malformed token', () => {
|
|
334
|
-
expect(getJWTTimeToExpiry('not-a-jwt')).toBe(0);
|
|
335
|
-
});
|
|
336
|
-
});
|
|
337
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for JWT Verification Utilities
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
decodeJWT,
|
|
7
|
+
verifyJWT,
|
|
8
|
+
verifyJWTSignatureOnly,
|
|
9
|
+
isJWTExpired,
|
|
10
|
+
getJWTTimeToExpiry,
|
|
11
|
+
type JWTPayload,
|
|
12
|
+
} from './jwt.js';
|
|
13
|
+
import { base64UrlEncode, base64UrlEncodeBytes } from '@xivdyetools/crypto';
|
|
14
|
+
import { createHmacKey } from './hmac.js';
|
|
15
|
+
|
|
16
|
+
// Helper to create a valid JWT for testing
|
|
17
|
+
async function createTestJWT(
|
|
18
|
+
payload: JWTPayload,
|
|
19
|
+
secret: string,
|
|
20
|
+
algorithm = 'HS256'
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
const header = { alg: algorithm, typ: 'JWT' };
|
|
23
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
24
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
25
|
+
|
|
26
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
27
|
+
const key = await createHmacKey(secret, 'sign');
|
|
28
|
+
const signature = await crypto.subtle.sign(
|
|
29
|
+
'HMAC',
|
|
30
|
+
key,
|
|
31
|
+
new TextEncoder().encode(signatureInput)
|
|
32
|
+
);
|
|
33
|
+
const signatureB64 = base64UrlEncodeBytes(new Uint8Array(signature));
|
|
34
|
+
|
|
35
|
+
return `${headerB64}.${payloadB64}.${signatureB64}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('jwt.ts', () => {
|
|
39
|
+
const secret = 'test-jwt-secret-key-123';
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('decodeJWT', () => {
|
|
51
|
+
it('should decode a valid JWT payload', async () => {
|
|
52
|
+
const payload: JWTPayload = {
|
|
53
|
+
sub: '123456789',
|
|
54
|
+
iat: Math.floor(Date.now() / 1000),
|
|
55
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
56
|
+
type: 'access',
|
|
57
|
+
username: 'testuser',
|
|
58
|
+
};
|
|
59
|
+
const token = await createTestJWT(payload, secret);
|
|
60
|
+
|
|
61
|
+
const decoded = decodeJWT(token);
|
|
62
|
+
|
|
63
|
+
expect(decoded).not.toBeNull();
|
|
64
|
+
expect(decoded?.sub).toBe('123456789');
|
|
65
|
+
expect(decoded?.type).toBe('access');
|
|
66
|
+
expect(decoded?.username).toBe('testuser');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return null for invalid token format', () => {
|
|
70
|
+
const decoded = decodeJWT('not.a.valid.token.format');
|
|
71
|
+
expect(decoded).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return null for malformed base64', () => {
|
|
75
|
+
const decoded = decodeJWT('not-base64.also-not.valid');
|
|
76
|
+
expect(decoded).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return null for invalid JSON payload', () => {
|
|
80
|
+
const headerB64 = base64UrlEncode('{"alg":"HS256","typ":"JWT"}');
|
|
81
|
+
const payloadB64 = base64UrlEncodeBytes(
|
|
82
|
+
new TextEncoder().encode('not-json')
|
|
83
|
+
);
|
|
84
|
+
const decoded = decodeJWT(`${headerB64}.${payloadB64}.signature`);
|
|
85
|
+
expect(decoded).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('verifyJWT', () => {
|
|
90
|
+
it('should verify a valid token', async () => {
|
|
91
|
+
const payload: JWTPayload = {
|
|
92
|
+
sub: '123456789',
|
|
93
|
+
iat: Math.floor(Date.now() / 1000),
|
|
94
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
95
|
+
type: 'access',
|
|
96
|
+
};
|
|
97
|
+
const token = await createTestJWT(payload, secret);
|
|
98
|
+
|
|
99
|
+
const verified = await verifyJWT(token, secret);
|
|
100
|
+
|
|
101
|
+
expect(verified).not.toBeNull();
|
|
102
|
+
expect(verified?.sub).toBe('123456789');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should return null for expired token', async () => {
|
|
106
|
+
const payload: JWTPayload = {
|
|
107
|
+
sub: '123456789',
|
|
108
|
+
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
109
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
|
110
|
+
type: 'access',
|
|
111
|
+
};
|
|
112
|
+
const token = await createTestJWT(payload, secret);
|
|
113
|
+
|
|
114
|
+
const verified = await verifyJWT(token, secret);
|
|
115
|
+
|
|
116
|
+
expect(verified).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return null for wrong secret', async () => {
|
|
120
|
+
const payload: JWTPayload = {
|
|
121
|
+
sub: '123456789',
|
|
122
|
+
iat: Math.floor(Date.now() / 1000),
|
|
123
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
124
|
+
type: 'access',
|
|
125
|
+
};
|
|
126
|
+
const token = await createTestJWT(payload, secret);
|
|
127
|
+
|
|
128
|
+
const verified = await verifyJWT(token, 'wrong-secret');
|
|
129
|
+
|
|
130
|
+
expect(verified).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return null for tampered payload', async () => {
|
|
134
|
+
const payload: JWTPayload = {
|
|
135
|
+
sub: '123456789',
|
|
136
|
+
iat: Math.floor(Date.now() / 1000),
|
|
137
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
138
|
+
type: 'access',
|
|
139
|
+
};
|
|
140
|
+
const token = await createTestJWT(payload, secret);
|
|
141
|
+
|
|
142
|
+
// Tamper with the payload
|
|
143
|
+
const parts = token.split('.');
|
|
144
|
+
const tamperedPayload = { ...payload, sub: 'tampered-id' };
|
|
145
|
+
parts[1] = base64UrlEncode(JSON.stringify(tamperedPayload));
|
|
146
|
+
const tamperedToken = parts.join('.');
|
|
147
|
+
|
|
148
|
+
const verified = await verifyJWT(tamperedToken, secret);
|
|
149
|
+
|
|
150
|
+
expect(verified).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should reject non-HS256 algorithm (security)', async () => {
|
|
154
|
+
const payload: JWTPayload = {
|
|
155
|
+
sub: '123456789',
|
|
156
|
+
iat: Math.floor(Date.now() / 1000),
|
|
157
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
158
|
+
type: 'access',
|
|
159
|
+
};
|
|
160
|
+
// Create token with different algorithm in header
|
|
161
|
+
const token = await createTestJWT(payload, secret, 'none');
|
|
162
|
+
|
|
163
|
+
const verified = await verifyJWT(token, secret);
|
|
164
|
+
|
|
165
|
+
expect(verified).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should return null for malformed token', async () => {
|
|
169
|
+
const verified = await verifyJWT('not-a-jwt', secret);
|
|
170
|
+
expect(verified).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle token without exp claim', async () => {
|
|
174
|
+
// Create a token without exp - should still work if signature is valid
|
|
175
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
176
|
+
const payload = {
|
|
177
|
+
sub: '123456789',
|
|
178
|
+
iat: Math.floor(Date.now() / 1000),
|
|
179
|
+
type: 'access',
|
|
180
|
+
};
|
|
181
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
182
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
183
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
184
|
+
const key = await createHmacKey(secret, 'sign');
|
|
185
|
+
const signature = await crypto.subtle.sign(
|
|
186
|
+
'HMAC',
|
|
187
|
+
key,
|
|
188
|
+
new TextEncoder().encode(signatureInput)
|
|
189
|
+
);
|
|
190
|
+
const signatureB64 = base64UrlEncodeBytes(new Uint8Array(signature));
|
|
191
|
+
const token = `${headerB64}.${payloadB64}.${signatureB64}`;
|
|
192
|
+
|
|
193
|
+
const verified = await verifyJWT(token, secret);
|
|
194
|
+
|
|
195
|
+
// Should pass since no exp means no expiration check
|
|
196
|
+
expect(verified).not.toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('verifyJWTSignatureOnly', () => {
|
|
201
|
+
it('should verify signature even for expired token', async () => {
|
|
202
|
+
const payload: JWTPayload = {
|
|
203
|
+
sub: '123456789',
|
|
204
|
+
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
205
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // Expired
|
|
206
|
+
type: 'refresh',
|
|
207
|
+
};
|
|
208
|
+
const token = await createTestJWT(payload, secret);
|
|
209
|
+
|
|
210
|
+
const verified = await verifyJWTSignatureOnly(token, secret);
|
|
211
|
+
|
|
212
|
+
expect(verified).not.toBeNull();
|
|
213
|
+
expect(verified?.sub).toBe('123456789');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should return null for invalid signature', async () => {
|
|
217
|
+
const payload: JWTPayload = {
|
|
218
|
+
sub: '123456789',
|
|
219
|
+
iat: Math.floor(Date.now() / 1000),
|
|
220
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
221
|
+
type: 'refresh',
|
|
222
|
+
};
|
|
223
|
+
const token = await createTestJWT(payload, secret);
|
|
224
|
+
|
|
225
|
+
const verified = await verifyJWTSignatureOnly(token, 'wrong-secret');
|
|
226
|
+
|
|
227
|
+
expect(verified).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should reject non-HS256 algorithm (security)', async () => {
|
|
231
|
+
const payload: JWTPayload = {
|
|
232
|
+
sub: '123456789',
|
|
233
|
+
iat: Math.floor(Date.now() / 1000),
|
|
234
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
235
|
+
type: 'refresh',
|
|
236
|
+
};
|
|
237
|
+
const token = await createTestJWT(payload, secret, 'HS384');
|
|
238
|
+
|
|
239
|
+
const verified = await verifyJWTSignatureOnly(token, secret);
|
|
240
|
+
|
|
241
|
+
expect(verified).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should respect maxAgeMs parameter', async () => {
|
|
245
|
+
const payload: JWTPayload = {
|
|
246
|
+
sub: '123456789',
|
|
247
|
+
iat: Math.floor(Date.now() / 1000) - 7200, // 2 hours ago
|
|
248
|
+
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
249
|
+
type: 'refresh',
|
|
250
|
+
};
|
|
251
|
+
const token = await createTestJWT(payload, secret);
|
|
252
|
+
|
|
253
|
+
// Should fail with 1 hour max age
|
|
254
|
+
const verified = await verifyJWTSignatureOnly(token, secret, 3600 * 1000);
|
|
255
|
+
|
|
256
|
+
expect(verified).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should accept token within maxAgeMs', async () => {
|
|
260
|
+
const payload: JWTPayload = {
|
|
261
|
+
sub: '123456789',
|
|
262
|
+
iat: Math.floor(Date.now() / 1000) - 1800, // 30 minutes ago
|
|
263
|
+
exp: Math.floor(Date.now() / 1000) - 900, // Expired 15 minutes ago
|
|
264
|
+
type: 'refresh',
|
|
265
|
+
};
|
|
266
|
+
const token = await createTestJWT(payload, secret);
|
|
267
|
+
|
|
268
|
+
// Should pass with 1 hour max age
|
|
269
|
+
const verified = await verifyJWTSignatureOnly(token, secret, 3600 * 1000);
|
|
270
|
+
|
|
271
|
+
expect(verified).not.toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('isJWTExpired', () => {
|
|
276
|
+
it('should return false for valid non-expired token', async () => {
|
|
277
|
+
const payload: JWTPayload = {
|
|
278
|
+
sub: '123456789',
|
|
279
|
+
iat: Math.floor(Date.now() / 1000),
|
|
280
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
281
|
+
type: 'access',
|
|
282
|
+
};
|
|
283
|
+
const token = await createTestJWT(payload, secret);
|
|
284
|
+
|
|
285
|
+
expect(isJWTExpired(token)).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should return true for expired token', async () => {
|
|
289
|
+
const payload: JWTPayload = {
|
|
290
|
+
sub: '123456789',
|
|
291
|
+
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
292
|
+
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
293
|
+
type: 'access',
|
|
294
|
+
};
|
|
295
|
+
const token = await createTestJWT(payload, secret);
|
|
296
|
+
|
|
297
|
+
expect(isJWTExpired(token)).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should return true for malformed token', () => {
|
|
301
|
+
expect(isJWTExpired('not-a-jwt')).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('getJWTTimeToExpiry', () => {
|
|
306
|
+
it('should return correct time to expiry', async () => {
|
|
307
|
+
const expiresIn = 3600; // 1 hour
|
|
308
|
+
const payload: JWTPayload = {
|
|
309
|
+
sub: '123456789',
|
|
310
|
+
iat: Math.floor(Date.now() / 1000),
|
|
311
|
+
exp: Math.floor(Date.now() / 1000) + expiresIn,
|
|
312
|
+
type: 'access',
|
|
313
|
+
};
|
|
314
|
+
const token = await createTestJWT(payload, secret);
|
|
315
|
+
|
|
316
|
+
const ttl = getJWTTimeToExpiry(token);
|
|
317
|
+
|
|
318
|
+
expect(ttl).toBe(expiresIn);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return 0 for expired token', async () => {
|
|
322
|
+
const payload: JWTPayload = {
|
|
323
|
+
sub: '123456789',
|
|
324
|
+
iat: Math.floor(Date.now() / 1000) - 7200,
|
|
325
|
+
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
326
|
+
type: 'access',
|
|
327
|
+
};
|
|
328
|
+
const token = await createTestJWT(payload, secret);
|
|
329
|
+
|
|
330
|
+
expect(getJWTTimeToExpiry(token)).toBe(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should return 0 for malformed token', () => {
|
|
334
|
+
expect(getJWTTimeToExpiry('not-a-jwt')).toBe(0);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|