@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/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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwt.js","sourceRoot":"","sources":["../src/jwt.ts"],"names":[],"mappings":"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"}
@@ -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
+ });