@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/dist/jwt.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
import { base64UrlEncodeBytes, base64UrlDecode, base64UrlDecodeBytes, } from '@xivdyetools/crypto';
|
|
15
|
+
import { createHmacKey } from './hmac.js';
|
|
16
|
+
/**
|
|
17
|
+
* Decode a JWT without verifying the signature.
|
|
18
|
+
*
|
|
19
|
+
* WARNING: Only use this for debugging or when you'll verify separately.
|
|
20
|
+
* For production use, always use `verifyJWT()`.
|
|
21
|
+
*
|
|
22
|
+
* @param token - The JWT string
|
|
23
|
+
* @returns Decoded payload or null if malformed
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const payload = decodeJWT(token);
|
|
28
|
+
* console.log('Token expires:', new Date(payload.exp * 1000));
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function decodeJWT(token) {
|
|
32
|
+
try {
|
|
33
|
+
const parts = token.split('.');
|
|
34
|
+
if (parts.length !== 3) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const payloadJson = base64UrlDecode(parts[1]);
|
|
38
|
+
return JSON.parse(payloadJson);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Verify a JWT and return the payload if valid.
|
|
46
|
+
*
|
|
47
|
+
* Performs full verification:
|
|
48
|
+
* 1. Validates token structure (3 parts)
|
|
49
|
+
* 2. Validates algorithm is HS256 (prevents confusion attacks)
|
|
50
|
+
* 3. Verifies HMAC-SHA256 signature
|
|
51
|
+
* 4. Checks expiration time
|
|
52
|
+
*
|
|
53
|
+
* @param token - The JWT string
|
|
54
|
+
* @param secret - The HMAC secret used to sign the token
|
|
55
|
+
* @returns Verified payload or null if invalid/expired
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const payload = await verifyJWT(token, env.JWT_SECRET);
|
|
60
|
+
* if (!payload) {
|
|
61
|
+
* return new Response('Unauthorized', { status: 401 });
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export async function verifyJWT(token, secret) {
|
|
66
|
+
try {
|
|
67
|
+
const parts = token.split('.');
|
|
68
|
+
if (parts.length !== 3) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
72
|
+
// Decode and validate header
|
|
73
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
74
|
+
const header = JSON.parse(headerJson);
|
|
75
|
+
// SECURITY: Reject non-HS256 algorithms (prevents algorithm confusion attacks)
|
|
76
|
+
if (header.alg !== 'HS256') {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// Verify signature
|
|
80
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
81
|
+
const key = await createHmacKey(secret, 'both');
|
|
82
|
+
const encoder = new TextEncoder();
|
|
83
|
+
const expectedSignature = await crypto.subtle.sign('HMAC', key, encoder.encode(signatureInput));
|
|
84
|
+
const expectedSignatureB64 = base64UrlEncodeBytes(new Uint8Array(expectedSignature));
|
|
85
|
+
// Compare signatures (using string comparison - both are base64url)
|
|
86
|
+
if (signatureB64 !== expectedSignatureB64) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// Decode payload
|
|
90
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
91
|
+
const payload = JSON.parse(payloadJson);
|
|
92
|
+
// Check expiration
|
|
93
|
+
const now = Math.floor(Date.now() / 1000);
|
|
94
|
+
if (payload.exp && payload.exp < now) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return payload;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Verify JWT signature only, ignoring expiration.
|
|
105
|
+
*
|
|
106
|
+
* Used for refresh tokens where we want to verify authenticity
|
|
107
|
+
* but allow some grace period past expiration.
|
|
108
|
+
*
|
|
109
|
+
* @param token - The JWT string
|
|
110
|
+
* @param secret - The HMAC secret
|
|
111
|
+
* @param maxAgeMs - Optional maximum age in milliseconds (from iat)
|
|
112
|
+
* @returns Payload if signature valid, null otherwise
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```typescript
|
|
116
|
+
* // Allow refresh tokens up to 7 days old
|
|
117
|
+
* const payload = await verifyJWTSignatureOnly(
|
|
118
|
+
* refreshToken,
|
|
119
|
+
* env.JWT_SECRET,
|
|
120
|
+
* 7 * 24 * 60 * 60 * 1000
|
|
121
|
+
* );
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export async function verifyJWTSignatureOnly(token, secret, maxAgeMs) {
|
|
125
|
+
try {
|
|
126
|
+
const parts = token.split('.');
|
|
127
|
+
if (parts.length !== 3) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
131
|
+
// Decode and validate header
|
|
132
|
+
const headerJson = base64UrlDecode(headerB64);
|
|
133
|
+
const header = JSON.parse(headerJson);
|
|
134
|
+
// SECURITY: Still reject non-HS256 algorithms
|
|
135
|
+
if (header.alg !== 'HS256') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
// Verify signature
|
|
139
|
+
const signatureInput = `${headerB64}.${payloadB64}`;
|
|
140
|
+
const key = await createHmacKey(secret, 'both');
|
|
141
|
+
const encoder = new TextEncoder();
|
|
142
|
+
const expectedSignature = await crypto.subtle.sign('HMAC', key, encoder.encode(signatureInput));
|
|
143
|
+
const expectedSignatureB64 = base64UrlEncodeBytes(new Uint8Array(expectedSignature));
|
|
144
|
+
if (signatureB64 !== expectedSignatureB64) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// Decode payload
|
|
148
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
149
|
+
const payload = JSON.parse(payloadJson);
|
|
150
|
+
// Check max age if specified
|
|
151
|
+
if (maxAgeMs !== undefined && payload.iat) {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const tokenAge = now - payload.iat * 1000;
|
|
154
|
+
if (tokenAge > maxAgeMs) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return payload;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Check if a JWT is expired without full verification.
|
|
166
|
+
*
|
|
167
|
+
* Useful for quick checks before making API calls.
|
|
168
|
+
*
|
|
169
|
+
* @param token - The JWT string
|
|
170
|
+
* @returns true if token is expired or malformed
|
|
171
|
+
*/
|
|
172
|
+
export function isJWTExpired(token) {
|
|
173
|
+
const payload = decodeJWT(token);
|
|
174
|
+
if (!payload || !payload.exp) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
const now = Math.floor(Date.now() / 1000);
|
|
178
|
+
return payload.exp < now;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get time until JWT expiration.
|
|
182
|
+
*
|
|
183
|
+
* @param token - The JWT string
|
|
184
|
+
* @returns Seconds until expiration, or 0 if expired/invalid
|
|
185
|
+
*/
|
|
186
|
+
export function getJWTTimeToExpiry(token) {
|
|
187
|
+
const payload = decodeJWT(token);
|
|
188
|
+
if (!payload || !payload.exp) {
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
const now = Math.floor(Date.now() / 1000);
|
|
192
|
+
return Math.max(0, payload.exp - now);
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=jwt.js.map
|
package/dist/jwt.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwt.js","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"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"}
|
package/dist/timing.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing-Safe Comparison Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides constant-time comparison to prevent timing attacks.
|
|
5
|
+
* Regular string comparison (===) can leak information about secrets
|
|
6
|
+
* because it short-circuits on the first non-matching character.
|
|
7
|
+
*
|
|
8
|
+
* @module timing
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Performs a constant-time string comparison to prevent timing attacks.
|
|
12
|
+
*
|
|
13
|
+
* Uses `crypto.subtle.timingSafeEqual()` when available (Cloudflare Workers),
|
|
14
|
+
* with a fallback XOR-based implementation for other environments.
|
|
15
|
+
*
|
|
16
|
+
* @param a - First string to compare
|
|
17
|
+
* @param b - Second string to compare
|
|
18
|
+
* @returns true if strings are equal, false otherwise
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const isValid = await timingSafeEqual(providedToken, expectedToken);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function timingSafeEqual(a: string, b: string): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Performs constant-time comparison on Uint8Arrays.
|
|
28
|
+
*
|
|
29
|
+
* @param a - First array to compare
|
|
30
|
+
* @param b - Second array to compare
|
|
31
|
+
* @returns true if arrays are equal, false otherwise
|
|
32
|
+
*/
|
|
33
|
+
export declare function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): Promise<boolean>;
|
|
34
|
+
//# sourceMappingURL=timing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA4B5E;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,UAAU,GACZ,OAAO,CAAC,OAAO,CAAC,CAkBlB"}
|
package/dist/timing.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing-Safe Comparison Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides constant-time comparison to prevent timing attacks.
|
|
5
|
+
* Regular string comparison (===) can leak information about secrets
|
|
6
|
+
* because it short-circuits on the first non-matching character.
|
|
7
|
+
*
|
|
8
|
+
* @module timing
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Performs a constant-time string comparison to prevent timing attacks.
|
|
12
|
+
*
|
|
13
|
+
* Uses `crypto.subtle.timingSafeEqual()` when available (Cloudflare Workers),
|
|
14
|
+
* with a fallback XOR-based implementation for other environments.
|
|
15
|
+
*
|
|
16
|
+
* @param a - First string to compare
|
|
17
|
+
* @param b - Second string to compare
|
|
18
|
+
* @returns true if strings are equal, false otherwise
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const isValid = await timingSafeEqual(providedToken, expectedToken);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export async function timingSafeEqual(a, b) {
|
|
26
|
+
const encoder = new TextEncoder();
|
|
27
|
+
const aBytes = encoder.encode(a);
|
|
28
|
+
const bBytes = encoder.encode(b);
|
|
29
|
+
// If lengths differ, we still need to do constant-time comparison
|
|
30
|
+
// to avoid leaking length information. Use the longer length.
|
|
31
|
+
const maxLength = Math.max(aBytes.length, bBytes.length);
|
|
32
|
+
// Pad shorter array to match length (prevents length-based timing leak)
|
|
33
|
+
const aPadded = new Uint8Array(maxLength);
|
|
34
|
+
const bPadded = new Uint8Array(maxLength);
|
|
35
|
+
aPadded.set(aBytes);
|
|
36
|
+
bPadded.set(bBytes);
|
|
37
|
+
// Use crypto.subtle.timingSafeEqual if available (Cloudflare Workers)
|
|
38
|
+
try {
|
|
39
|
+
const result = await crypto.subtle.timingSafeEqual(aPadded, bPadded);
|
|
40
|
+
// Also check original lengths matched
|
|
41
|
+
return result && aBytes.length === bBytes.length;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Fallback: manual constant-time comparison (for environments without timingSafeEqual)
|
|
45
|
+
let diff = aBytes.length ^ bBytes.length;
|
|
46
|
+
for (let i = 0; i < maxLength; i++) {
|
|
47
|
+
diff |= aPadded[i] ^ bPadded[i];
|
|
48
|
+
}
|
|
49
|
+
return diff === 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Performs constant-time comparison on Uint8Arrays.
|
|
54
|
+
*
|
|
55
|
+
* @param a - First array to compare
|
|
56
|
+
* @param b - Second array to compare
|
|
57
|
+
* @returns true if arrays are equal, false otherwise
|
|
58
|
+
*/
|
|
59
|
+
export async function timingSafeEqualBytes(a, b) {
|
|
60
|
+
const maxLength = Math.max(a.length, b.length);
|
|
61
|
+
const aPadded = new Uint8Array(maxLength);
|
|
62
|
+
const bPadded = new Uint8Array(maxLength);
|
|
63
|
+
aPadded.set(a);
|
|
64
|
+
bPadded.set(b);
|
|
65
|
+
try {
|
|
66
|
+
const result = await crypto.subtle.timingSafeEqual(aPadded, bPadded);
|
|
67
|
+
return result && a.length === b.length;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
let diff = a.length ^ b.length;
|
|
71
|
+
for (let i = 0; i < maxLength; i++) {
|
|
72
|
+
diff |= aPadded[i] ^ bPadded[i];
|
|
73
|
+
}
|
|
74
|
+
return diff === 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=timing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timing.js","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,CAAS,EAAE,CAAS;IACxD,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IACjC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAEjC,kEAAkE;IAClE,8DAA8D;IAC9D,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IAEzD,wEAAwE;IACxE,MAAM,OAAO,GAAG,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC;IAC1C,MAAM,OAAO,GAAG,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC;IAC1C,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACpB,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAEpB,sEAAsE;IACtE,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACrE,sCAAsC;QACtC,OAAO,MAAM,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,uFAAuF;QACvF,IAAI,IAAI,GAAG,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,CAAC,EAAE,EAAE,CAAC;YACnC,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QAClC,CAAC;QACD,OAAO,IAAI,KAAK,CAAC,CAAC;IACpB,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,CAAa,EACb,CAAa;IAEb,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC;IAE/C,MAAM,OAAO,GAAG,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC;IAC1C,MAAM,OAAO,GAAG,IAAI,UAAU,CAAC,SAAS,CAAC,CAAC;IAC1C,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACf,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAEf,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,eAAe,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACrE,OAAO,MAAM,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,CAAC;IACzC,CAAC;IAAC,MAAM,CAAC;QACP,IAAI,IAAI,GAAG,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;QAC/B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,CAAC,EAAE,EAAE,CAAC;YACnC,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QAClC,CAAC;QACD,OAAO,IAAI,KAAK,CAAC,CAAC;IACpB,CAAC;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xivdyetools/auth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared authentication utilities for xivdyetools ecosystem",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./jwt": {
|
|
14
|
+
"types": "./dist/jwt.d.ts",
|
|
15
|
+
"import": "./dist/jwt.js"
|
|
16
|
+
},
|
|
17
|
+
"./hmac": {
|
|
18
|
+
"types": "./dist/hmac.d.ts",
|
|
19
|
+
"import": "./dist/hmac.js"
|
|
20
|
+
},
|
|
21
|
+
"./timing": {
|
|
22
|
+
"types": "./dist/timing.d.ts",
|
|
23
|
+
"import": "./dist/timing.js"
|
|
24
|
+
},
|
|
25
|
+
"./discord": {
|
|
26
|
+
"types": "./dist/discord.d.ts",
|
|
27
|
+
"import": "./dist/discord.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"type-check": "tsc --noEmit",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"test:coverage": "vitest run --coverage",
|
|
40
|
+
"clean": "rimraf dist"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@xivdyetools/crypto": "^1.0.0",
|
|
44
|
+
"discord-interactions": "^4.4.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@cloudflare/workers-types": "^4.20241224.0",
|
|
48
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
49
|
+
"rimraf": "^6.0.1",
|
|
50
|
+
"typescript": "^5.7.2",
|
|
51
|
+
"vitest": "^2.1.8"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@cloudflare/workers-types": "^4.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@cloudflare/workers-types": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"keywords": [
|
|
62
|
+
"xivdyetools",
|
|
63
|
+
"auth",
|
|
64
|
+
"jwt",
|
|
65
|
+
"hmac",
|
|
66
|
+
"discord",
|
|
67
|
+
"cloudflare-workers"
|
|
68
|
+
],
|
|
69
|
+
"author": "XIVDyeTools",
|
|
70
|
+
"license": "MIT"
|
|
71
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Discord Request Verification
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
verifyDiscordRequest,
|
|
7
|
+
unauthorizedResponse,
|
|
8
|
+
badRequestResponse,
|
|
9
|
+
} from './discord.js';
|
|
10
|
+
|
|
11
|
+
// Mock discord-interactions
|
|
12
|
+
vi.mock('discord-interactions', () => ({
|
|
13
|
+
verifyKey: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { verifyKey } from 'discord-interactions';
|
|
17
|
+
|
|
18
|
+
describe('discord.ts', () => {
|
|
19
|
+
const mockPublicKey = 'test-public-key-123';
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('verifyDiscordRequest', () => {
|
|
26
|
+
it('should return valid result for valid signature', async () => {
|
|
27
|
+
vi.mocked(verifyKey).mockResolvedValue(true);
|
|
28
|
+
|
|
29
|
+
const request = new Request('https://example.com/interactions', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'X-Signature-Ed25519': 'valid-signature',
|
|
34
|
+
'X-Signature-Timestamp': '1234567890',
|
|
35
|
+
'Content-Length': '50',
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({ type: 1 }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
41
|
+
|
|
42
|
+
expect(result.isValid).toBe(true);
|
|
43
|
+
expect(result.body).toBe(JSON.stringify({ type: 1 }));
|
|
44
|
+
expect(result.error).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should return invalid result for invalid signature', async () => {
|
|
48
|
+
vi.mocked(verifyKey).mockResolvedValue(false);
|
|
49
|
+
|
|
50
|
+
const request = new Request('https://example.com/interactions', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'X-Signature-Ed25519': 'invalid-signature',
|
|
55
|
+
'X-Signature-Timestamp': '1234567890',
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({ type: 1 }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
61
|
+
|
|
62
|
+
expect(result.isValid).toBe(false);
|
|
63
|
+
expect(result.error).toBe('Invalid signature');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should reject request with missing signature header', async () => {
|
|
67
|
+
const request = new Request('https://example.com/interactions', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
'X-Signature-Timestamp': '1234567890',
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ type: 1 }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
77
|
+
|
|
78
|
+
expect(result.isValid).toBe(false);
|
|
79
|
+
expect(result.error).toBe('Missing signature headers');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should reject request with missing timestamp header', async () => {
|
|
83
|
+
const request = new Request('https://example.com/interactions', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
'X-Signature-Ed25519': 'some-signature',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ type: 1 }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
93
|
+
|
|
94
|
+
expect(result.isValid).toBe(false);
|
|
95
|
+
expect(result.error).toBe('Missing signature headers');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should reject request with Content-Length exceeding limit', async () => {
|
|
99
|
+
const request = new Request('https://example.com/interactions', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'application/json',
|
|
103
|
+
'X-Signature-Ed25519': 'some-signature',
|
|
104
|
+
'X-Signature-Timestamp': '1234567890',
|
|
105
|
+
'Content-Length': '200000', // 200KB, exceeds 100KB limit
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify({ type: 1 }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
111
|
+
|
|
112
|
+
expect(result.isValid).toBe(false);
|
|
113
|
+
expect(result.error).toBe('Request body too large');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should reject request with actual body exceeding limit', async () => {
|
|
117
|
+
// Create a large body
|
|
118
|
+
const largeBody = 'x'.repeat(150_000); // 150KB
|
|
119
|
+
|
|
120
|
+
const request = new Request('https://example.com/interactions', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
'X-Signature-Ed25519': 'some-signature',
|
|
125
|
+
'X-Signature-Timestamp': '1234567890',
|
|
126
|
+
// No Content-Length header to bypass first check
|
|
127
|
+
},
|
|
128
|
+
body: largeBody,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
132
|
+
|
|
133
|
+
expect(result.isValid).toBe(false);
|
|
134
|
+
expect(result.error).toBe('Request body too large');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should respect custom maxBodySize option', async () => {
|
|
138
|
+
const request = new Request('https://example.com/interactions', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'X-Signature-Ed25519': 'some-signature',
|
|
143
|
+
'X-Signature-Timestamp': '1234567890',
|
|
144
|
+
'Content-Length': '500', // 500 bytes
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({ type: 1 }),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const result = await verifyDiscordRequest(request, mockPublicKey, {
|
|
150
|
+
maxBodySize: 100, // Only allow 100 bytes
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.isValid).toBe(false);
|
|
154
|
+
expect(result.error).toBe('Request body too large');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should handle verifyKey throwing an error', async () => {
|
|
158
|
+
vi.mocked(verifyKey).mockRejectedValue(new Error('Verification error'));
|
|
159
|
+
|
|
160
|
+
const request = new Request('https://example.com/interactions', {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: {
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
'X-Signature-Ed25519': 'some-signature',
|
|
165
|
+
'X-Signature-Timestamp': '1234567890',
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({ type: 1 }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
171
|
+
|
|
172
|
+
expect(result.isValid).toBe(false);
|
|
173
|
+
expect(result.error).toBe('Verification error');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should handle non-Error exceptions from verifyKey', async () => {
|
|
177
|
+
vi.mocked(verifyKey).mockRejectedValue('string error');
|
|
178
|
+
|
|
179
|
+
const request = new Request('https://example.com/interactions', {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
'X-Signature-Ed25519': 'some-signature',
|
|
184
|
+
'X-Signature-Timestamp': '1234567890',
|
|
185
|
+
},
|
|
186
|
+
body: JSON.stringify({ type: 1 }),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
190
|
+
|
|
191
|
+
expect(result.isValid).toBe(false);
|
|
192
|
+
expect(result.error).toBe('Verification failed');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should include body in result even on invalid signature', async () => {
|
|
196
|
+
vi.mocked(verifyKey).mockResolvedValue(false);
|
|
197
|
+
|
|
198
|
+
const bodyContent = JSON.stringify({ type: 2, data: { name: 'test' } });
|
|
199
|
+
const request = new Request('https://example.com/interactions', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: {
|
|
202
|
+
'Content-Type': 'application/json',
|
|
203
|
+
'X-Signature-Ed25519': 'invalid-signature',
|
|
204
|
+
'X-Signature-Timestamp': '1234567890',
|
|
205
|
+
},
|
|
206
|
+
body: bodyContent,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await verifyDiscordRequest(request, mockPublicKey);
|
|
210
|
+
|
|
211
|
+
expect(result.isValid).toBe(false);
|
|
212
|
+
expect(result.body).toBe(bodyContent);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('unauthorizedResponse', () => {
|
|
217
|
+
it('should return 401 response with default message', () => {
|
|
218
|
+
const response = unauthorizedResponse();
|
|
219
|
+
|
|
220
|
+
expect(response.status).toBe(401);
|
|
221
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should return 401 response with custom message', async () => {
|
|
225
|
+
const response = unauthorizedResponse('Custom error message');
|
|
226
|
+
const body = await response.json();
|
|
227
|
+
|
|
228
|
+
expect(response.status).toBe(401);
|
|
229
|
+
expect(body.error).toBe('Custom error message');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('badRequestResponse', () => {
|
|
234
|
+
it('should return 400 response with message', async () => {
|
|
235
|
+
const response = badRequestResponse('Bad request error');
|
|
236
|
+
const body = await response.json();
|
|
237
|
+
|
|
238
|
+
expect(response.status).toBe(400);
|
|
239
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
240
|
+
expect(body.error).toBe('Bad request error');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|