@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/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';
@@ -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
+ }