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