Skip to content

Commit 3635a1a

Browse files
committed
feat(security): Add centralized middleware for authentication, rate limiting and security headers
1 parent 0c8d1d7 commit 3635a1a

8 files changed

Lines changed: 148 additions & 96 deletions

auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
2121
callbacks: {
2222
async jwt({ token, account }) {
2323
if (account?.access_token) {
24-
token.ghToken = encryptToken(account.access_token);
24+
token.ghToken = await encryptToken(account.access_token);
2525
}
2626
return token;
2727
},

lib/crypto.empty-fallback.test.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,75 +10,75 @@ afterEach(() => {
1010
});
1111

1212
describe('crypto empty / missing inputs verification', () => {
13-
it('encrypts and decrypts a normal token string', () => {
13+
it('encrypts and decrypts a normal token string', async () => {
1414
const plain = 'gho_abc123def456token';
15-
const encrypted = encryptToken(plain);
15+
const encrypted = await encryptToken(plain);
1616
expect(encrypted).toBeDefined();
1717
expect(encrypted).not.toBe(plain);
1818
expect(encrypted.split('.')).toHaveLength(4);
19-
expect(decryptToken(encrypted)).toBe(plain);
19+
expect(await decryptToken(encrypted)).toBe(plain);
2020
});
2121

22-
it('handles empty string encryption and decryption', () => {
23-
const encrypted = encryptToken('');
22+
it('handles empty string encryption and decryption', async () => {
23+
const encrypted = await encryptToken('');
2424
expect(encrypted.split('.')).toHaveLength(4);
25-
expect(decryptToken(encrypted)).toBe('');
25+
expect(await decryptToken(encrypted)).toBe('');
2626
});
2727

28-
it('rejects malformed payload with wrong number of parts', () => {
29-
expect(() => decryptToken('only-one-part')).toThrow();
30-
expect(() => decryptToken('two.parts')).toThrow();
31-
expect(() => decryptToken('a.b.c.d')).toThrow();
28+
it('rejects malformed payload with wrong number of parts', async () => {
29+
await expect(decryptToken('only-one-part')).rejects.toThrow();
30+
await expect(decryptToken('two.parts')).rejects.toThrow();
31+
await expect(decryptToken('a.b.c.d')).rejects.toThrow();
3232
});
3333

34-
it('rejects payload with invalid base64', () => {
34+
it('rejects payload with invalid base64', async () => {
3535
const payload = '!!!invalid-base64!!!.aaaa.aaaa';
36-
expect(() => decryptToken(payload)).toThrow();
36+
await expect(decryptToken(payload)).rejects.toThrow();
3737
});
3838

39-
it('rejects empty payload string', () => {
40-
expect(() => decryptToken('')).toThrow();
39+
it('rejects empty payload string', async () => {
40+
await expect(decryptToken('')).rejects.toThrow();
4141
});
4242

43-
it('rejects tampered ciphertext (modified encrypted part)', () => {
43+
it('rejects tampered ciphertext (modified encrypted part)', async () => {
4444
const plain = 'gho_secret_token';
45-
const encrypted = encryptToken(plain);
45+
const encrypted = await encryptToken(plain);
4646
const parts = encrypted.split('.');
4747
const tampered = [parts[0], parts[1], '////'].join('.');
48-
expect(() => decryptToken(tampered)).toThrow();
48+
await expect(decryptToken(tampered)).rejects.toThrow();
4949
});
5050

51-
it('rejects tampered auth tag (modified tag part)', () => {
51+
it('rejects tampered auth tag (modified tag part)', async () => {
5252
const plain = 'gho_secret_token';
53-
const encrypted = encryptToken(plain);
53+
const encrypted = await encryptToken(plain);
5454
const parts = encrypted.split('.');
5555
const tampered = [parts[0], '////', parts[2]].join('.');
56-
expect(() => decryptToken(tampered)).toThrow();
56+
await expect(decryptToken(tampered)).rejects.toThrow();
5757
});
5858

59-
it('rejects tampered IV (modified iv part)', () => {
59+
it('rejects tampered IV (modified iv part)', async () => {
6060
const plain = 'gho_secret_token';
61-
const encrypted = encryptToken(plain);
61+
const encrypted = await encryptToken(plain);
6262
const parts = encrypted.split('.');
6363
const tampered = ['////', parts[1], parts[2]].join('.');
64-
expect(() => decryptToken(tampered)).toThrow();
64+
await expect(decryptToken(tampered)).rejects.toThrow();
6565
});
6666
});
6767

6868
describe('crypto key errors', () => {
69-
it('throws when ENCRYPTION_KEY is missing', () => {
69+
it('throws when ENCRYPTION_KEY is missing', async () => {
7070
vi.stubEnv('ENCRYPTION_KEY', '');
71-
expect(() => encryptToken('test')).toThrow(/ENCRYPTION_KEY/i);
71+
await expect(encryptToken('test')).rejects.toThrow(/ENCRYPTION_KEY/i);
7272
});
7373

74-
it('throws when ENCRYPTION_KEY is too short', () => {
74+
it('throws when ENCRYPTION_KEY is too short', async () => {
7575
vi.stubEnv('ENCRYPTION_KEY', 'short');
76-
expect(() => encryptToken('test')).toThrow(/ENCRYPTION_KEY/i);
76+
await expect(encryptToken('test')).rejects.toThrow(/ENCRYPTION_KEY/i);
7777
});
7878

79-
it('throws on decrypt with wrong key', () => {
80-
const encrypted = encryptToken('secret');
79+
it('throws on decrypt with wrong key', async () => {
80+
const encrypted = await encryptToken('secret');
8181
vi.stubEnv('ENCRYPTION_KEY', 'a-different-key-that-is-32-char!!');
82-
expect(() => decryptToken(encrypted)).toThrow();
82+
await expect(decryptToken(encrypted)).rejects.toThrow();
8383
});
8484
});

lib/crypto.massive-scaling.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,50 +10,50 @@ afterEach(() => {
1010
});
1111

1212
describe('crypto massive scaling', () => {
13-
it('handles very long token strings without truncation', () => {
13+
it('handles very long token strings without truncation', async () => {
1414
const long = 'a'.repeat(10_000);
15-
const encrypted = encryptToken(long);
16-
expect(decryptToken(encrypted)).toBe(long);
15+
const encrypted = await encryptToken(long);
16+
expect(await decryptToken(encrypted)).toBe(long);
1717
});
1818

19-
it('handles unicode characters including emoji', () => {
19+
it('handles unicode characters including emoji', async () => {
2020
const unicode = '🚀🔥💯 commitpulse 测试 テスト тест';
21-
const encrypted = encryptToken(unicode);
22-
expect(decryptToken(encrypted)).toBe(unicode);
21+
const encrypted = await encryptToken(unicode);
22+
expect(await decryptToken(encrypted)).toBe(unicode);
2323
});
2424

25-
it('handles strings with special characters', () => {
25+
it('handles strings with special characters', async () => {
2626
const special = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~';
27-
const encrypted = encryptToken(special);
28-
expect(decryptToken(encrypted)).toBe(special);
27+
const encrypted = await encryptToken(special);
28+
expect(await decryptToken(encrypted)).toBe(special);
2929
});
3030

31-
it('handles JSON-serialized payload', () => {
31+
it('handles JSON-serialized payload', async () => {
3232
const payload = JSON.stringify({
3333
access_token: 'gho_xxx',
3434
scope: 'repo,user',
3535
token_type: 'bearer',
3636
});
37-
const encrypted = encryptToken(payload);
38-
expect(decryptToken(encrypted)).toBe(payload);
37+
const encrypted = await encryptToken(payload);
38+
expect(await decryptToken(encrypted)).toBe(payload);
3939
});
4040

41-
it('maintains round-trip integrity for repeated encryptions of same plaintext (different IV)', () => {
41+
it('maintains round-trip integrity for repeated encryptions of same plaintext (different IV)', async () => {
4242
const plain = 'gho_consistent_token_value';
4343
const results = new Set<string>();
4444
for (let i = 0; i < 10; i++) {
45-
results.add(encryptToken(plain));
45+
results.add(await encryptToken(plain));
4646
}
4747
expect(results.size).toBe(10);
48-
results.forEach((enc) => {
49-
expect(decryptToken(enc)).toBe(plain);
50-
});
48+
for (const enc of Array.from(results)) {
49+
expect(await decryptToken(enc)).toBe(plain);
50+
}
5151
});
5252

53-
it('handles maximum token length (512 bytes) typical for GitHub tokens', () => {
53+
it('handles maximum token length (512 bytes) typical for GitHub tokens', async () => {
5454
const typical = 'ghp_' + 'a'.repeat(508);
5555
expect(typical.length).toBe(512);
56-
const encrypted = encryptToken(typical);
57-
expect(decryptToken(encrypted)).toBe(typical);
56+
const encrypted = await encryptToken(typical);
57+
expect(await decryptToken(encrypted)).toBe(typical);
5858
});
5959
});

lib/crypto.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,52 +13,52 @@ afterAll(() => {
1313
});
1414

1515
describe('encryptToken / decryptToken', () => {
16-
it('round-trips a plain text token', () => {
16+
it('round-trips a plain text token', async () => {
1717
const plain = 'ghp_test123token';
18-
const enc = encryptToken(plain);
18+
const enc = await encryptToken(plain);
1919
expect(enc).not.toBe(plain);
20-
expect(decryptToken(enc)).toBe(plain);
20+
expect(await decryptToken(enc)).toBe(plain);
2121
});
2222

23-
it('produces different ciphertexts for the same input (random salt/IV)', () => {
23+
it('produces different ciphertexts for the same input (random salt/IV)', async () => {
2424
const plain = 'same-value';
25-
const a = encryptToken(plain);
26-
const b = encryptToken(plain);
25+
const a = await encryptToken(plain);
26+
const b = await encryptToken(plain);
2727
expect(a).not.toBe(b);
2828
});
2929

30-
it('rejects a tampered ciphertext', () => {
31-
const enc = encryptToken('secret');
30+
it('rejects a tampered ciphertext', async () => {
31+
const enc = await encryptToken('secret');
3232
const parts = enc.split('.');
3333
parts[2] = Buffer.from('ffffffffffffffff').toString('base64');
34-
expect(() => decryptToken(parts.join('.'))).toThrow();
34+
await expect(decryptToken(parts.join('.'))).rejects.toThrow();
3535
});
3636

37-
it('throws on invalid payload format', () => {
38-
expect(() => decryptToken('not-a-valid-format')).toThrow();
37+
it('throws on invalid payload format', async () => {
38+
await expect(decryptToken('not-a-valid-format')).rejects.toThrow();
3939
});
4040

41-
it('handles empty string', () => {
42-
const enc = encryptToken('');
43-
expect(decryptToken(enc)).toBe('');
41+
it('handles empty string', async () => {
42+
const enc = await encryptToken('');
43+
expect(await decryptToken(enc)).toBe('');
4444
});
4545

46-
it('handles special characters', () => {
46+
it('handles special characters', async () => {
4747
const plain = 'abc123!@#$%^&*()_+=-[]{}|;:,.<>?/~`';
48-
expect(decryptToken(encryptToken(plain))).toBe(plain);
48+
expect(await decryptToken(await encryptToken(plain))).toBe(plain);
4949
});
5050

51-
it('throws when ENCRYPTION_KEY is missing', () => {
51+
it('throws when ENCRYPTION_KEY is missing', async () => {
5252
const saved = process.env.ENCRYPTION_KEY;
5353
delete process.env.ENCRYPTION_KEY;
54-
expect(() => encryptToken('x')).toThrow('ENCRYPTION_KEY');
54+
await expect(encryptToken('x')).rejects.toThrow('ENCRYPTION_KEY');
5555
process.env.ENCRYPTION_KEY = saved;
5656
});
5757

58-
it('throws when ENCRYPTION_KEY is too short', () => {
58+
it('throws when ENCRYPTION_KEY is too short', async () => {
5959
const saved = process.env.ENCRYPTION_KEY;
6060
process.env.ENCRYPTION_KEY = 'short';
61-
expect(() => encryptToken('x')).toThrow('ENCRYPTION_KEY');
61+
await expect(encryptToken('x')).rejects.toThrow('ENCRYPTION_KEY');
6262
process.env.ENCRYPTION_KEY = saved;
6363
});
6464
});

lib/crypto.ts

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,78 @@
11
import 'server-only';
2-
import crypto from 'node:crypto';
32

4-
const ALGO = 'aes-256-gcm';
3+
// Polyfill global crypto for older environments if needed
4+
const webCrypto = typeof crypto !== 'undefined' ? crypto : globalThis.crypto;
55

6-
function key(salt: Buffer): Buffer {
6+
const ALGO = 'AES-GCM';
7+
8+
async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
79
const k = process.env.ENCRYPTION_KEY;
810
if (!k || k.length < 32) {
911
throw new Error('ENCRYPTION_KEY must be at least 32 characters');
1012
}
11-
return crypto.pbkdf2Sync(k, salt, 100000, 32, 'sha512');
13+
14+
const baseKey = await webCrypto.subtle.importKey(
15+
'raw',
16+
new TextEncoder().encode(k),
17+
'PBKDF2',
18+
false,
19+
['deriveKey']
20+
);
21+
22+
return webCrypto.subtle.deriveKey(
23+
{
24+
name: 'PBKDF2',
25+
salt: salt as BufferSource,
26+
iterations: 100000,
27+
hash: 'SHA-512',
28+
},
29+
baseKey,
30+
{ name: ALGO, length: 256 },
31+
false,
32+
['encrypt', 'decrypt']
33+
);
1234
}
1335

14-
export function encryptToken(plain: string): string {
15-
const salt = crypto.randomBytes(16);
16-
const iv = crypto.randomBytes(12);
17-
const cipher = crypto.createCipheriv(ALGO, key(salt), iv);
18-
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
19-
const tag = cipher.getAuthTag();
20-
return [salt, iv, tag, enc].map((b) => b.toString('base64')).join('.');
36+
export async function encryptToken(plain: string): Promise<string> {
37+
const salt = webCrypto.getRandomValues(new Uint8Array(16));
38+
const iv = webCrypto.getRandomValues(new Uint8Array(12));
39+
const derivedKey = await deriveKey(salt);
40+
41+
const encoded = new TextEncoder().encode(plain);
42+
const encryptedBuffer = await webCrypto.subtle.encrypt(
43+
{ name: ALGO, iv, tagLength: 128 },
44+
derivedKey,
45+
encoded
46+
);
47+
48+
const encryptedBytes = new Uint8Array(encryptedBuffer);
49+
const enc = encryptedBytes.slice(0, -16);
50+
const tag = encryptedBytes.slice(-16);
51+
52+
const toBase64 = (arr: Uint8Array) => Buffer.from(arr).toString('base64');
53+
54+
return [toBase64(salt), toBase64(iv), toBase64(tag), toBase64(enc)].join('.');
2155
}
2256

23-
export function decryptToken(payload: string): string {
24-
const [salt, iv, tag, enc] = payload.split('.').map((p) => Buffer.from(p, 'base64'));
25-
const decipher = crypto.createDecipheriv(ALGO, key(salt), iv);
26-
decipher.setAuthTag(tag);
27-
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
57+
export async function decryptToken(payload: string): Promise<string> {
58+
const parts = payload.split('.');
59+
if (parts.length !== 4) {
60+
throw new Error('Invalid payload format');
61+
}
62+
63+
const [salt, iv, tag, enc] = parts.map((p) => new Uint8Array(Buffer.from(p, 'base64')));
64+
65+
const derivedKey = await deriveKey(salt);
66+
67+
const cipherTextWithTag = new Uint8Array(enc.length + tag.length);
68+
cipherTextWithTag.set(enc);
69+
cipherTextWithTag.set(tag, enc.length);
70+
71+
const decryptedBuffer = await webCrypto.subtle.decrypt(
72+
{ name: ALGO, iv, tagLength: 128 },
73+
derivedKey,
74+
cipherTextWithTag
75+
);
76+
77+
return new TextDecoder().decode(decryptedBuffer);
2878
}

lib/githubtoken.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe('getUserGitHubToken', () => {
2424

2525
it('decrypts the GitHub token from the JWT cookie', async () => {
2626
const plainToken = 'gho_test_access_token';
27-
vi.mocked(getToken).mockResolvedValue({ ghToken: encryptToken(plainToken) });
27+
vi.mocked(getToken).mockResolvedValue({ ghToken: await encryptToken(plainToken) });
2828

2929
await expect(getUserGitHubToken()).resolves.toBe(plainToken);
3030
expect(getToken).toHaveBeenCalledWith(

lib/githubtoken.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export async function getUserGitHubToken(): Promise<string | undefined> {
2525
if (!jwt?.ghToken || typeof jwt.ghToken !== 'string') return undefined;
2626

2727
try {
28-
return decryptToken(jwt.ghToken);
28+
return await decryptToken(jwt.ghToken);
2929
} catch {
3030
return undefined; // corrupt/expired -> use global fallback
3131
}

0 commit comments

Comments
 (0)