@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/README.md +171 -158
- package/dist/hmac.d.ts +6 -0
- package/dist/hmac.d.ts.map +1 -1
- package/dist/hmac.js +49 -7
- package/dist/hmac.js.map +1 -1
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +44 -51
- 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 +339 -325
- package/src/hmac.ts +274 -222
- package/src/index.ts +54 -54
- package/src/jwt.test.ts +337 -337
- package/src/jwt.ts +248 -265
- package/src/timing.test.ts +114 -117
- package/src/timing.ts +86 -84
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 {
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
}
|