Skip to content

Commit ace79ea

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 1b9430a commit ace79ea

2 files changed

Lines changed: 200 additions & 148 deletions

File tree

Lines changed: 156 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import Fastify from 'fastify';
3-
import jwt from '@fastify/jwt';
43
import { connectRoutes } from '../routes/connect.js';
5-
import type { PrismaClient } from '@prisma/client';
64

75
process.env.PUBLIC_APP_URL = 'http://localhost:3000';
86
process.env.BACKEND_URL = 'http://localhost:3001';
@@ -11,9 +9,12 @@ process.env.GITHUB_CLIENT_ID = 'test-client-id';
119
process.env.GITHUB_CLIENT_SECRET = 'test-client-secret';
1210
process.env.ENCRYPTION_KEY = '12345678901234567890123456789012';
1311

12+
const VALID_NONCE = 'a'.repeat(64);
13+
const USER_ID = 'user-abc';
14+
1415
const mockRedis = {
15-
get: vi.fn(),
1616
set: vi.fn(),
17+
get: vi.fn(),
1718
del: vi.fn(),
1819
};
1920

@@ -25,163 +26,223 @@ const mockPrisma = {
2526
},
2627
};
2728

28-
global.fetch = vi.fn();
29-
30-
async function buildApp() {
29+
async function buildApp(authenticatedUserId = USER_ID) {
3130
const app = Fastify();
32-
await app.register(jwt, { secret: 'test-secret' });
33-
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
3431
app.decorate('redis', mockRedis as any);
35-
36-
app.decorate('authenticate', async (request: any, reply: any) => {
37-
try {
38-
await request.jwtVerify();
39-
} catch (err) {
40-
reply.status(401).send({ error: 'Unauthorized' });
41-
}
32+
app.decorate('prisma', mockPrisma as any);
33+
app.decorate('authenticate', async (request: any) => {
34+
request.user = { id: authenticatedUserId };
4235
});
43-
4436
app.register(connectRoutes, { prefix: '/api/connect' });
4537
await app.ready();
4638
return app;
4739
}
4840

41+
describe('GET /api/connect/github', () => {
42+
beforeEach(() => vi.clearAllMocks());
43+
44+
it('stores nonce in redis with userId and redirects to GitHub', async () => {
45+
const app = await buildApp();
46+
const res = await app.inject({ method: 'GET', url: '/api/connect/github' });
47+
48+
expect(res.statusCode).toBe(302);
49+
expect(res.headers.location).toMatch(/^https:\/\/github\.com\/login\/oauth\/authorize/);
50+
expect(mockRedis.set).toHaveBeenCalledOnce();
51+
52+
const [key, value, , ttl] = mockRedis.set.mock.calls[0];
53+
expect(key).toMatch(/^oauth:connect-nonce:[0-9a-f]{64}$/);
54+
const stored = JSON.parse(value);
55+
expect(stored.userId).toBe(USER_ID);
56+
expect(ttl).toBe(600);
57+
58+
// nonce in redirect state must match stored key
59+
const location = res.headers.location as string;
60+
const stateParam = new URL(location).searchParams.get('state');
61+
expect(key).toBe(`oauth:connect-nonce:${stateParam}`);
62+
});
63+
64+
it('state param is 64 lowercase hex chars', async () => {
65+
const app = await buildApp();
66+
const res = await app.inject({ method: 'GET', url: '/api/connect/github' });
67+
const location = res.headers.location as string;
68+
const stateParam = new URL(location).searchParams.get('state');
69+
expect(stateParam).toMatch(/^[0-9a-f]{64}$/);
70+
});
71+
});
72+
4973
describe('GET /api/connect/github/callback', () => {
50-
beforeEach(() => {
51-
vi.clearAllMocks();
74+
beforeEach(() => vi.clearAllMocks());
75+
76+
it('valid flow: completes connect, writes github_follow token, redirects to settings', async () => {
77+
mockRedis.get.mockResolvedValue(JSON.stringify({ userId: USER_ID }));
78+
mockRedis.del.mockResolvedValue(1);
79+
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
80+
81+
global.fetch = vi.fn().mockResolvedValue({
82+
json: async () => ({ access_token: 'gh-token', scope: 'user:follow' }),
83+
}) as any;
84+
85+
const app = await buildApp();
86+
const res = await app.inject({
87+
method: 'GET',
88+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
89+
});
90+
91+
expect(res.statusCode).toBe(302);
92+
expect(res.headers.location).toContain('connected=github');
93+
expect(mockRedis.del).toHaveBeenCalledWith(`oauth:connect-nonce:${VALID_NONCE}`);
94+
expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledWith(
95+
expect.objectContaining({
96+
where: { userId_platform: { userId: USER_ID, platform: 'github_follow' } },
97+
})
98+
);
5299
});
53100

54-
it('redirects with missing_params if code or state is missing', async () => {
101+
it('missing code redirects with missing_params error', async () => {
55102
const app = await buildApp();
56-
57-
// Missing code
58-
let res = await app.inject({
103+
const res = await app.inject({
59104
method: 'GET',
60-
url: '/api/connect/github/callback?state=somestate',
105+
url: `/api/connect/github/callback?state=${VALID_NONCE}`,
61106
});
62107
expect(res.statusCode).toBe(302);
63-
expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params');
108+
expect(res.headers.location).toContain('error=missing_params');
109+
expect(mockRedis.get).not.toHaveBeenCalled();
110+
});
64111

65-
// Missing state
66-
res = await app.inject({
112+
it('missing state redirects with missing_params error', async () => {
113+
const app = await buildApp();
114+
const res = await app.inject({
67115
method: 'GET',
68-
url: '/api/connect/github/callback?code=somecode',
116+
url: '/api/connect/github/callback?code=validcode',
69117
});
70118
expect(res.statusCode).toBe(302);
71-
expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params');
119+
expect(res.headers.location).toContain('error=missing_params');
72120
});
73121

74-
it('redirects with connect_failed if state is invalid/malformed', async () => {
122+
it('malformed state (too short) redirects with connect_failed', async () => {
75123
const app = await buildApp();
76-
const invalidState = Buffer.from(JSON.stringify({ wrongKey: 'value' })).toString('base64');
77-
78124
const res = await app.inject({
79125
method: 'GET',
80-
url: `/api/connect/github/callback?code=testcode&state=${invalidState}`,
126+
url: '/api/connect/github/callback?code=validcode&state=tooshort',
81127
});
82-
83128
expect(res.statusCode).toBe(302);
84-
expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed');
129+
expect(res.headers.location).toContain('error=connect_failed');
130+
expect(mockRedis.get).not.toHaveBeenCalled();
85131
});
86132

87-
it('redirects with invalid_state if nonce is not found in Redis (CSRF/Expired)', async () => {
88-
mockRedis.get.mockResolvedValue(null);
133+
it('malformed state (non-hex chars) redirects with connect_failed', async () => {
89134
const app = await buildApp();
90-
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64');
91-
135+
const badState = 'z'.repeat(64);
92136
const res = await app.inject({
93137
method: 'GET',
94-
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
138+
url: `/api/connect/github/callback?code=validcode&state=${badState}`,
95139
});
96-
97-
expect(mockRedis.get).toHaveBeenCalledWith('oauth:nonce:nonce-123');
98140
expect(res.statusCode).toBe(302);
99-
expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state');
141+
expect(res.headers.location).toContain('error=connect_failed');
142+
expect(mockRedis.get).not.toHaveBeenCalled();
100143
});
101144

102-
it('redirects with invalid_state if Redis userId does not match state userId', async () => {
103-
mockRedis.get.mockResolvedValue('different-user-id');
145+
it('forged base64-JSON state is rejected as malformed', async () => {
104146
const app = await buildApp();
105-
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64');
106-
147+
const forgedState = Buffer.from(JSON.stringify({ userId: USER_ID, nonce: 'x'.repeat(32) })).toString('base64');
107148
const res = await app.inject({
108149
method: 'GET',
109-
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
150+
url: `/api/connect/github/callback?code=validcode&state=${forgedState}`,
110151
});
111-
112152
expect(res.statusCode).toBe(302);
113-
expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state');
153+
expect(res.headers.location).toContain('error=connect_failed');
154+
expect(mockRedis.get).not.toHaveBeenCalled();
114155
});
115156

116-
it('successfully exchanges code, upserts token, and redirects on valid flow (Web)', async () => {
117-
mockRedis.get.mockResolvedValue('user-1');
118-
(global.fetch as any).mockResolvedValue({
119-
json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' })
157+
it('unknown nonce (not in Redis) redirects with connect_failed', async () => {
158+
mockRedis.get.mockResolvedValue(null);
159+
const app = await buildApp();
160+
const res = await app.inject({
161+
method: 'GET',
162+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
120163
});
121-
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
164+
expect(res.statusCode).toBe(302);
165+
expect(res.headers.location).toContain('error=connect_failed');
166+
expect(mockRedis.del).not.toHaveBeenCalled();
167+
});
122168

169+
it('expired nonce (Redis returns null) redirects with connect_failed', async () => {
170+
mockRedis.get.mockResolvedValue(null);
123171
const app = await buildApp();
124-
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'web_nonce-123' })).toString('base64');
125-
126172
const res = await app.inject({
127173
method: 'GET',
128-
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
174+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
129175
});
130-
131-
// Nonce should be deleted immediately
132-
expect(mockRedis.del).toHaveBeenCalledWith('oauth:nonce:web_nonce-123');
133-
134-
// Code exchange should be triggered
135-
expect(global.fetch).toHaveBeenCalledWith('https://github.com/login/oauth/access_token', expect.objectContaining({
136-
method: 'POST',
137-
body: expect.stringContaining('testcode')
138-
}));
139-
140-
// Upsert should be called
141-
expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledWith(expect.objectContaining({
142-
where: { userId_platform: { userId: 'user-1', platform: 'github_follow' } }
143-
}));
144-
145-
// Redirects to web success
146176
expect(res.statusCode).toBe(302);
147-
expect(res.headers.location).toBe('http://localhost:3000/settings?connected=github');
177+
expect(res.headers.location).toContain('error=connect_failed');
148178
});
149179

150-
it('redirects to mobile scheme if nonce starts with mobile_', async () => {
151-
mockRedis.get.mockResolvedValue('user-1');
152-
(global.fetch as any).mockResolvedValue({
153-
json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' })
180+
it('forged state (random nonce not stored in Redis) redirects with connect_failed', async () => {
181+
mockRedis.get.mockResolvedValue(null);
182+
const forgedNonce = 'b'.repeat(64);
183+
const app = await buildApp();
184+
const res = await app.inject({
185+
method: 'GET',
186+
url: `/api/connect/github/callback?code=validcode&state=${forgedNonce}`,
154187
});
188+
expect(res.statusCode).toBe(302);
189+
expect(res.headers.location).toContain('error=connect_failed');
190+
});
191+
192+
it('replay attack: second use of same nonce fails after first succeeds', async () => {
193+
mockRedis.del.mockResolvedValue(1);
155194
mockPrisma.oAuthToken.upsert.mockResolvedValue({});
156195

196+
global.fetch = vi.fn().mockResolvedValue({
197+
json: async () => ({ access_token: 'gh-token', scope: 'user:follow' }),
198+
}) as any;
199+
200+
// First call succeeds
201+
mockRedis.get.mockResolvedValueOnce(JSON.stringify({ userId: USER_ID }));
202+
const app = await buildApp();
203+
const res1 = await app.inject({
204+
method: 'GET',
205+
url: `/api/connect/github/callback?code=code1&state=${VALID_NONCE}`,
206+
});
207+
expect(res1.statusCode).toBe(302);
208+
expect(res1.headers.location).toContain('connected=github');
209+
210+
// Second call: nonce deleted, Redis returns null
211+
mockRedis.get.mockResolvedValueOnce(null);
212+
const res2 = await app.inject({
213+
method: 'GET',
214+
url: `/api/connect/github/callback?code=code2&state=${VALID_NONCE}`,
215+
});
216+
expect(res2.statusCode).toBe(302);
217+
expect(res2.headers.location).toContain('error=connect_failed');
218+
});
219+
220+
it('corrupt nonce data in Redis redirects with connect_failed', async () => {
221+
mockRedis.get.mockResolvedValue('not-valid-json{{{');
222+
mockRedis.del.mockResolvedValue(1);
157223
const app = await buildApp();
158-
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'mobile_nonce-123' })).toString('base64');
159-
160224
const res = await app.inject({
161225
method: 'GET',
162-
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
226+
url: `/api/connect/github/callback?code=validcode&state=${VALID_NONCE}`,
163227
});
164-
165228
expect(res.statusCode).toBe(302);
166-
expect(res.headers.location).toBe('devcard://connect?connected=github');
229+
expect(res.headers.location).toContain('error=connect_failed');
167230
});
168231

169-
it('redirects with connect_failed if token exchange returns an error', async () => {
170-
mockRedis.get.mockResolvedValue('user-1');
171-
(global.fetch as any).mockResolvedValue({
172-
json: vi.fn().mockResolvedValue({ error: 'bad_verification_code' })
173-
});
232+
it('GitHub token exchange failure redirects with connect_failed', async () => {
233+
mockRedis.get.mockResolvedValue(JSON.stringify({ userId: USER_ID }));
234+
mockRedis.del.mockResolvedValue(1);
235+
236+
global.fetch = vi.fn().mockResolvedValue({
237+
json: async () => ({ error: 'bad_verification_code' }),
238+
}) as any;
174239

175240
const app = await buildApp();
176-
const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64');
177-
178241
const res = await app.inject({
179242
method: 'GET',
180-
url: `/api/connect/github/callback?code=testcode&state=${validState}`,
243+
url: `/api/connect/github/callback?code=badcode&state=${VALID_NONCE}`,
181244
});
182-
183-
expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled();
184245
expect(res.statusCode).toBe(302);
185-
expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed');
246+
expect(res.headers.location).toContain('error=connect_failed');
186247
});
187-
});
248+
});

0 commit comments

Comments
 (0)