Skip to content

Commit c52744a

Browse files
committed
fix(connect): validate OAuth state nonce server-side to prevent CSRF (closes #223)
Replace client-side-only state validation in the GitHub connect flow with server-side nonce verification backed by Redis. - Generate a 32-byte cryptographically secure nonce on connect initiation - Store {userId} in Redis under oauth:connect-nonce:<nonce> with a 10-minute TTL - State parameter carries only the nonce; userId is never trusted from the callback - On callback: validate nonce format, look up Redis, reject unknown/expired nonces - Delete nonce immediately after first successful validation (replay protection) - Drop Math.random()-based state generation in favour of randomBytes(32) - Add 13 tests covering: valid flow, missing params, malformed state, unknown nonce, expired nonce, forged state, replay attack, corrupt Redis data, token exchange failure
1 parent ff7f939 commit c52744a

2 files changed

Lines changed: 274 additions & 78 deletions

File tree

Lines changed: 220 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,229 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import Fastify from 'fastify';
3+
import { connectRoutes } from '../routes/connect.js';
24

3-
// Mock test for GitHub OAuth callback state validation
4-
// Note: This test verifies the expected behavior of the
5-
// /api/connect/github/callback endpoint when invalid or
6-
// malformed OAuth state values are received.
7-
//
8-
// The implementation in connect.ts now:
9-
// - safely parses OAuth state via parseGoogleState()
10-
// - validates required fields (userId + nonce)
11-
// - redirects invalid callbacks safely
12-
//
13-
// Security note:
14-
// OAuth state validation helps prevent tampered callback
15-
// requests and malformed state payload attacks.
5+
const VALID_NONCE = 'a'.repeat(64);
6+
const USER_ID = 'user-abc';
167

17-
describe('GET /api/connect/github/callback - Invalid OAuth State', () => {
8+
const mockRedis = {
9+
set: vi.fn(),
10+
get: vi.fn(),
11+
del: vi.fn(),
12+
};
1813

19-
it('should redirect with connect_failed when state is invalid', async () => {
20-
// Expected behavior:
21-
// parseGoogleState('invalid_state') -> null
22-
// reply.redirect(`${PUBLIC_APP_URL}/settings?error=connect_failed`)
14+
const mockPrisma = {
15+
oAuthToken: {
16+
findMany: vi.fn(),
17+
upsert: vi.fn(),
18+
delete: vi.fn(),
19+
},
20+
};
2321

24-
expect(true).toBe(true);
22+
const mockEncryption = {
23+
encrypt: vi.fn().mockReturnValue('encrypted-token'),
24+
};
25+
26+
async function buildApp(authenticatedUserId = USER_ID) {
27+
const app = Fastify();
28+
app.decorate('redis', mockRedis as any);
29+
app.decorate('prisma', mockPrisma as any);
30+
app.decorate('encryption', mockEncryption as any);
31+
app.decorate('authenticate', async (request: any) => {
32+
request.user = { id: authenticatedUserId };
33+
});
34+
app.register(connectRoutes, { prefix: '/api/connect' });
35+
await app.ready();
36+
return app;
37+
}
38+
39+
describe('GET /api/connect/github', () => {
40+
beforeEach(() => vi.clearAllMocks());
41+
42+
it('stores nonce in redis with userId and redirects to GitHub', async () => {
43+
const app = await buildApp();
44+
const res = await app.inject({ method: 'GET', url: '/api/connect/github' });
45+
46+
expect(res.statusCode).toBe(302);
47+
expect(res.headers.location).toMatch(/^https:\/\/github\.com\/login\/oauth\/authorize/);
48+
expect(mockRedis.set).toHaveBeenCalledOnce();
49+
50+
const [key, value, , ttl] = mockRedis.set.mock.calls[0];
51+
expect(key).toMatch(/^oauth:connect-nonce:[0-9a-f]{64}$/);
52+
const stored = JSON.parse(value);
53+
expect(stored.userId).toBe(USER_ID);
54+
expect(ttl).toBe(600);
55+
56+
// nonce in redirect state must match stored key
57+
const location = res.headers.location as string;
58+
const stateParam = new URL(location).searchParams.get('state');
59+
expect(key).toBe(`oauth:connect-nonce:${stateParam}`);
60+
});
61+
62+
it('state param is 64 lowercase hex chars', async () => {
63+
const app = await buildApp();
64+
const res = await app.inject({ method: 'GET', url: '/api/connect/github' });
65+
const location = res.headers.location as string;
66+
const stateParam = new URL(location).searchParams.get('state');
67+
expect(stateParam).toMatch(/^[0-9a-f]{64}$/);
68+
});
69+
});
70+
71+
describe('GET /api/connect/github/callback', () => {
72+
beforeEach(() => vi.clearAllMocks());
73+
74+
it('valid flow: completes connect and redirects to settings', async () => {
75+
mockRedis.get.mockResolvedValue(JSON.stringify({ userId: USER_ID }));
76+
mockRedis.del.mockResolvedValue(1);
77+
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
78+
79+
global.fetch = vi.fn().mockResolvedValue({
80+
json: async () => ({ access_token: 'gh-token', scope: 'user:follow' }),
81+
}) as any;
82+
83+
const app = await buildApp();
84+
const res = await app.inject({
85+
method: 'GET',
86+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
87+
});
88+
89+
expect(res.statusCode).toBe(302);
90+
expect(res.headers.location).toContain('connected=github');
91+
expect(mockRedis.del).toHaveBeenCalledWith(`oauth:connect-nonce:${VALID_NONCE}`);
92+
});
93+
94+
it('missing code redirects with missing_params error', async () => {
95+
const app = await buildApp();
96+
const res = await app.inject({
97+
method: 'GET',
98+
url: `/api/connect/github/callback?state=${VALID_NONCE}`,
99+
});
100+
expect(res.statusCode).toBe(302);
101+
expect(res.headers.location).toContain('error=missing_params');
102+
expect(mockRedis.get).not.toHaveBeenCalled();
103+
});
104+
105+
it('missing state redirects with missing_params error', async () => {
106+
const app = await buildApp();
107+
const res = await app.inject({
108+
method: 'GET',
109+
url: '/api/connect/github/callback?code=validcode',
110+
});
111+
expect(res.statusCode).toBe(302);
112+
expect(res.headers.location).toContain('error=missing_params');
113+
});
114+
115+
it('malformed state (too short) redirects with connect_failed', async () => {
116+
const app = await buildApp();
117+
const res = await app.inject({
118+
method: 'GET',
119+
url: '/api/connect/github/callback?code=validcode&state=tooshort',
25120
});
121+
expect(res.statusCode).toBe(302);
122+
expect(res.headers.location).toContain('error=connect_failed');
123+
expect(mockRedis.get).not.toHaveBeenCalled();
124+
});
26125

27-
it('should reject malformed oauth state payloads', async () => {
28-
// Example malformed payload:
29-
// { invalid: true }
30-
//
31-
// Expected:
32-
// - missing userId
33-
// - missing nonce
34-
// - redirect to connect_failed
126+
it('malformed state (non-hex chars) redirects with connect_failed', async () => {
127+
const app = await buildApp();
128+
const badState = 'z'.repeat(64);
129+
const res = await app.inject({
130+
method: 'GET',
131+
url: `/api/connect/github/callback?code=validcode&state=${badState}`,
132+
});
133+
expect(res.statusCode).toBe(302);
134+
expect(res.headers.location).toContain('error=connect_failed');
135+
expect(mockRedis.get).not.toHaveBeenCalled();
136+
});
35137

36-
expect(true).toBe(true);
138+
it('unknown nonce (not in Redis) redirects with connect_failed', async () => {
139+
mockRedis.get.mockResolvedValue(null);
140+
const app = await buildApp();
141+
const res = await app.inject({
142+
method: 'GET',
143+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
37144
});
145+
expect(res.statusCode).toBe(302);
146+
expect(res.headers.location).toContain('error=connect_failed');
147+
expect(mockRedis.del).not.toHaveBeenCalled();
148+
});
38149

39-
});
150+
it('expired nonce (Redis returns null) redirects with connect_failed', async () => {
151+
mockRedis.get.mockResolvedValue(null);
152+
const app = await buildApp();
153+
const res = await app.inject({
154+
method: 'GET',
155+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
156+
});
157+
expect(res.statusCode).toBe(302);
158+
expect(res.headers.location).toContain('error=connect_failed');
159+
});
160+
161+
it('forged state (random nonce not stored in Redis) redirects with connect_failed', async () => {
162+
mockRedis.get.mockResolvedValue(null);
163+
const forgedNonce = 'b'.repeat(64);
164+
const app = await buildApp();
165+
const res = await app.inject({
166+
method: 'GET',
167+
url: `/api/connect/github/callback?code=validcode&state=${forgedNonce}`,
168+
});
169+
expect(res.statusCode).toBe(302);
170+
expect(res.headers.location).toContain('error=connect_failed');
171+
});
172+
173+
it('replay attack: second use of same nonce fails after first succeeds', async () => {
174+
mockRedis.del.mockResolvedValue(1);
175+
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
176+
177+
global.fetch = vi.fn().mockResolvedValue({
178+
json: async () => ({ access_token: 'gh-token', scope: 'user:follow' }),
179+
}) as any;
180+
181+
// First call succeeds
182+
mockRedis.get.mockResolvedValueOnce(JSON.stringify({ userId: USER_ID }));
183+
const app = await buildApp();
184+
const res1 = await app.inject({
185+
method: 'GET',
186+
url: `/api/connect/github/callback?code=code1&state=${VALID_NONCE}`,
187+
});
188+
expect(res1.statusCode).toBe(302);
189+
expect(res1.headers.location).toContain('connected=github');
190+
191+
// Second call: nonce deleted, Redis returns null
192+
mockRedis.get.mockResolvedValueOnce(null);
193+
const res2 = await app.inject({
194+
method: 'GET',
195+
url: `/api/connect/github/callback?code=code2&state=${VALID_NONCE}`,
196+
});
197+
expect(res2.statusCode).toBe(302);
198+
expect(res2.headers.location).toContain('error=connect_failed');
199+
});
200+
201+
it('corrupt nonce data in Redis redirects with connect_failed', async () => {
202+
mockRedis.get.mockResolvedValue('not-valid-json{{{');
203+
mockRedis.del.mockResolvedValue(1);
204+
const app = await buildApp();
205+
const res = await app.inject({
206+
method: 'GET',
207+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
208+
});
209+
expect(res.statusCode).toBe(302);
210+
expect(res.headers.location).toContain('error=connect_failed');
211+
});
212+
213+
it('GitHub token exchange failure redirects with connect_failed', async () => {
214+
mockRedis.get.mockResolvedValue(JSON.stringify({ userId: USER_ID }));
215+
mockRedis.del.mockResolvedValue(1);
216+
217+
global.fetch = vi.fn().mockResolvedValue({
218+
json: async () => ({ error: 'bad_verification_code' }),
219+
}) as any;
220+
221+
const app = await buildApp();
222+
const res = await app.inject({
223+
method: 'GET',
224+
url: `/api/connect/github/callback?code=badcode&state=${VALID_NONCE}`,
225+
});
226+
expect(res.statusCode).toBe(302);
227+
expect(res.headers.location).toContain('error=connect_failed');
228+
});
229+
});

0 commit comments

Comments
 (0)