@xivdyetools/auth 1.0.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/dist/discord.d.ts +65 -0
- package/dist/discord.d.ts.map +1 -0
- package/dist/discord.js +107 -0
- package/dist/discord.js.map +1 -0
- package/dist/hmac.d.ts +101 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +153 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +110 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +194 -0
- package/dist/jwt.js.map +1 -0
- package/dist/timing.d.ts +34 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +77 -0
- package/dist/timing.js.map +1 -0
- package/package.json +71 -0
- package/src/discord.test.ts +243 -0
- package/src/discord.ts +143 -0
- package/src/hmac.test.ts +325 -0
- package/src/hmac.ts +213 -0
- package/src/index.ts +54 -0
- package/src/jwt.test.ts +337 -0
- package/src/jwt.ts +267 -0
- package/src/timing.test.ts +117 -0
- package/src/timing.ts +84 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xivdyetools/auth
|
|
3
|
+
*
|
|
4
|
+
* Shared authentication utilities for the xivdyetools ecosystem.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { verifyJWT, verifyDiscordRequest, timingSafeEqual } from '@xivdyetools/auth';
|
|
9
|
+
*
|
|
10
|
+
* // Verify JWT
|
|
11
|
+
* const payload = await verifyJWT(token, env.JWT_SECRET);
|
|
12
|
+
*
|
|
13
|
+
* // Verify Discord request
|
|
14
|
+
* const result = await verifyDiscordRequest(request, env.DISCORD_PUBLIC_KEY);
|
|
15
|
+
*
|
|
16
|
+
* // Timing-safe comparison
|
|
17
|
+
* const isValid = await timingSafeEqual(provided, expected);
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @module @xivdyetools/auth
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// JWT utilities
|
|
24
|
+
export {
|
|
25
|
+
verifyJWT,
|
|
26
|
+
verifyJWTSignatureOnly,
|
|
27
|
+
decodeJWT,
|
|
28
|
+
isJWTExpired,
|
|
29
|
+
getJWTTimeToExpiry,
|
|
30
|
+
type JWTPayload,
|
|
31
|
+
} from './jwt.js';
|
|
32
|
+
|
|
33
|
+
// HMAC utilities
|
|
34
|
+
export {
|
|
35
|
+
createHmacKey,
|
|
36
|
+
hmacSign,
|
|
37
|
+
hmacSignHex,
|
|
38
|
+
hmacVerify,
|
|
39
|
+
hmacVerifyHex,
|
|
40
|
+
verifyBotSignature,
|
|
41
|
+
type BotSignatureOptions,
|
|
42
|
+
} from './hmac.js';
|
|
43
|
+
|
|
44
|
+
// Timing-safe utilities
|
|
45
|
+
export { timingSafeEqual, timingSafeEqualBytes } from './timing.js';
|
|
46
|
+
|
|
47
|
+
// Discord verification
|
|
48
|
+
export {
|
|
49
|
+
verifyDiscordRequest,
|
|
50
|
+
unauthorizedResponse,
|
|
51
|
+
badRequestResponse,
|
|
52
|
+
type DiscordVerificationResult,
|
|
53
|
+
type DiscordVerifyOptions,
|
|
54
|
+
} from './discord.js';
|
package/src/jwt.test.ts
ADDED
|
@@ -0,0 +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
|
+
});
|
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Verification Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides JWT verification using HMAC-SHA256 (HS256) with the Web Crypto API.
|
|
5
|
+
* Intentionally does NOT include JWT creation - that stays in the oauth service.
|
|
6
|
+
*
|
|
7
|
+
* Security features:
|
|
8
|
+
* - Algorithm validation (rejects non-HS256 tokens)
|
|
9
|
+
* - Expiration checking
|
|
10
|
+
* - Timing-safe signature comparison
|
|
11
|
+
*
|
|
12
|
+
* @module jwt
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
base64UrlEncodeBytes,
|
|
17
|
+
base64UrlDecode,
|
|
18
|
+
base64UrlDecodeBytes,
|
|
19
|
+
} from '@xivdyetools/crypto';
|
|
20
|
+
import { createHmacKey } from './hmac.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* JWT payload structure
|
|
24
|
+
*
|
|
25
|
+
* Re-exported from @xivdyetools/types for convenience.
|
|
26
|
+
* Consumers should import from here rather than directly from types.
|
|
27
|
+
*/
|
|
28
|
+
export interface JWTPayload {
|
|
29
|
+
/** Subject - Discord user ID */
|
|
30
|
+
sub: string;
|
|
31
|
+
/** Issued at timestamp (seconds) */
|
|
32
|
+
iat: number;
|
|
33
|
+
/** Expiration timestamp (seconds) */
|
|
34
|
+
exp: number;
|
|
35
|
+
/** Token type: 'access' or 'refresh' */
|
|
36
|
+
type: 'access' | 'refresh';
|
|
37
|
+
/** Discord username */
|
|
38
|
+
username?: string;
|
|
39
|
+
/** Discord avatar hash */
|
|
40
|
+
avatar?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* JWT header structure
|
|
45
|
+
*/
|
|
46
|
+
interface JWTHeader {
|
|
47
|
+
alg: string;
|
|
48
|
+
typ: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decode a JWT without verifying the signature.
|
|
53
|
+
*
|
|
54
|
+
* WARNING: Only use this for debugging or when you'll verify separately.
|
|
55
|
+
* For production use, always use `verifyJWT()`.
|
|
56
|
+
*
|
|
57
|
+
* @param token - The JWT string
|
|
58
|
+
* @returns Decoded payload or null if malformed
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const payload = decodeJWT(token);
|
|
63
|
+
* console.log('Token expires:', new Date(payload.exp * 1000));
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function decodeJWT(token: string): JWTPayload | null {
|
|
67
|
+
try {
|
|
68
|
+
const parts = token.split('.');
|
|
69
|
+
if (parts.length !== 3) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const payloadJson = base64UrlDecode(parts[1]);
|
|
74
|
+
return JSON.parse(payloadJson) as JWTPayload;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify a JWT and return the payload if valid.
|
|
82
|
+
*
|
|
83
|
+
* Performs full verification:
|
|
84
|
+
* 1. Validates token structure (3 parts)
|
|
85
|
+
* 2. Validates algorithm is HS256 (prevents confusion attacks)
|
|
86
|
+
* 3. Verifies HMAC-SHA256 signature
|
|
87
|
+
* 4. Checks expiration time
|
|
88
|
+
*
|
|
89
|
+
* @param token - The JWT string
|
|
90
|
+
* @param secret - The HMAC secret used to sign the token
|
|
91
|
+
* @returns Verified payload or null if invalid/expired
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* const payload = await verifyJWT(token, env.JWT_SECRET);
|
|
96
|
+
* if (!payload) {
|
|
97
|
+
* return new Response('Unauthorized', { status: 401 });
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyJWT(
|
|
102
|
+
token: string,
|
|
103
|
+
secret: string
|
|
104
|
+
): Promise<JWTPayload | null> {
|
|
105
|
+
try {
|
|
106
|
+
const parts = token.split('.');
|
|
107
|
+
if (parts.length !== 3) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
112
|
+
|
|
113
|
+
// Decode and validate header
|
|
114
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
115
|
+
const header: JWTHeader = JSON.parse(headerJson);
|
|
116
|
+
|
|
117
|
+
// SECURITY: Reject non-HS256 algorithms (prevents algorithm confusion attacks)
|
|
118
|
+
if (header.alg !== 'HS256') {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Verify signature
|
|
123
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
124
|
+
const key = await createHmacKey(secret, 'both');
|
|
125
|
+
const encoder = new TextEncoder();
|
|
126
|
+
|
|
127
|
+
const expectedSignature = await crypto.subtle.sign(
|
|
128
|
+
'HMAC',
|
|
129
|
+
key,
|
|
130
|
+
encoder.encode(signatureInput)
|
|
131
|
+
);
|
|
132
|
+
const expectedSignatureB64 = base64UrlEncodeBytes(
|
|
133
|
+
new Uint8Array(expectedSignature)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Compare signatures (using string comparison - both are base64url)
|
|
137
|
+
if (signatureB64 !== expectedSignatureB64) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Decode payload
|
|
142
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
143
|
+
const payload: JWTPayload = JSON.parse(payloadJson);
|
|
144
|
+
|
|
145
|
+
// Check expiration
|
|
146
|
+
const now = Math.floor(Date.now() / 1000);
|
|
147
|
+
if (payload.exp && payload.exp < now) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return payload;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Verify JWT signature only, ignoring expiration.
|
|
159
|
+
*
|
|
160
|
+
* Used for refresh tokens where we want to verify authenticity
|
|
161
|
+
* but allow some grace period past expiration.
|
|
162
|
+
*
|
|
163
|
+
* @param token - The JWT string
|
|
164
|
+
* @param secret - The HMAC secret
|
|
165
|
+
* @param maxAgeMs - Optional maximum age in milliseconds (from iat)
|
|
166
|
+
* @returns Payload if signature valid, null otherwise
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```typescript
|
|
170
|
+
* // Allow refresh tokens up to 7 days old
|
|
171
|
+
* const payload = await verifyJWTSignatureOnly(
|
|
172
|
+
* refreshToken,
|
|
173
|
+
* env.JWT_SECRET,
|
|
174
|
+
* 7 * 24 * 60 * 60 * 1000
|
|
175
|
+
* );
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export async function verifyJWTSignatureOnly(
|
|
179
|
+
token: string,
|
|
180
|
+
secret: string,
|
|
181
|
+
maxAgeMs?: number
|
|
182
|
+
): Promise<JWTPayload | null> {
|
|
183
|
+
try {
|
|
184
|
+
const parts = token.split('.');
|
|
185
|
+
if (parts.length !== 3) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
190
|
+
|
|
191
|
+
// Decode and validate header
|
|
192
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
193
|
+
const header: JWTHeader = JSON.parse(headerJson);
|
|
194
|
+
|
|
195
|
+
// SECURITY: Still reject non-HS256 algorithms
|
|
196
|
+
if (header.alg !== 'HS256') {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Verify signature
|
|
201
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
202
|
+
const key = await createHmacKey(secret, 'both');
|
|
203
|
+
const encoder = new TextEncoder();
|
|
204
|
+
|
|
205
|
+
const expectedSignature = await crypto.subtle.sign(
|
|
206
|
+
'HMAC',
|
|
207
|
+
key,
|
|
208
|
+
encoder.encode(signatureInput)
|
|
209
|
+
);
|
|
210
|
+
const expectedSignatureB64 = base64UrlEncodeBytes(
|
|
211
|
+
new Uint8Array(expectedSignature)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (signatureB64 !== expectedSignatureB64) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Decode payload
|
|
219
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
220
|
+
const payload: JWTPayload = JSON.parse(payloadJson);
|
|
221
|
+
|
|
222
|
+
// Check max age if specified
|
|
223
|
+
if (maxAgeMs !== undefined && payload.iat) {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const tokenAge = now - payload.iat * 1000;
|
|
226
|
+
if (tokenAge > maxAgeMs) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return payload;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if a JWT is expired without full verification.
|
|
239
|
+
*
|
|
240
|
+
* Useful for quick checks before making API calls.
|
|
241
|
+
*
|
|
242
|
+
* @param token - The JWT string
|
|
243
|
+
* @returns true if token is expired or malformed
|
|
244
|
+
*/
|
|
245
|
+
export function isJWTExpired(token: string): boolean {
|
|
246
|
+
const payload = decodeJWT(token);
|
|
247
|
+
if (!payload || !payload.exp) {
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
const now = Math.floor(Date.now() / 1000);
|
|
251
|
+
return payload.exp < now;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get time until JWT expiration.
|
|
256
|
+
*
|
|
257
|
+
* @param token - The JWT string
|
|
258
|
+
* @returns Seconds until expiration, or 0 if expired/invalid
|
|
259
|
+
*/
|
|
260
|
+
export function getJWTTimeToExpiry(token: string): number {
|
|
261
|
+
const payload = decodeJWT(token);
|
|
262
|
+
if (!payload || !payload.exp) {
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
const now = Math.floor(Date.now() / 1000);
|
|
266
|
+
return Math.max(0, payload.exp - now);
|
|
267
|
+
}
|