Skip to content

Commit a8c2647

Browse files
authored
Merge pull request #1117 from constructive-io/feat/cookie-lifecycle-csrf-749
feat(server): cookie lifecycle & CSRF enforcement (#749)
2 parents 0b17051 + 9b0e35f commit a8c2647

17 files changed

Lines changed: 2365 additions & 6 deletions

graphql/server/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"backend"
4242
],
4343
"dependencies": {
44+
"@constructive-io/csrf": "workspace:^",
4445
"@constructive-io/graphql-env": "workspace:^",
4546
"@constructive-io/graphql-types": "workspace:^",
4647
"@constructive-io/s3-utils": "workspace:^",
@@ -79,12 +80,14 @@
7980
},
8081
"devDependencies": {
8182
"@aws-sdk/client-s3": "^3.1009.0",
83+
"@types/cookie-parser": "^1.4.10",
8284
"@types/cors": "^2.8.17",
8385
"@types/express": "^5.0.6",
8486
"@types/graphql-upload": "^8.0.12",
8587
"@types/multer": "^2.1.0",
8688
"@types/pg": "^8.18.0",
8789
"@types/request-ip": "^0.0.41",
90+
"cookie-parser": "^1.4.7",
8891
"graphile-test": "workspace:*",
8992
"makage": "^0.3.0",
9093
"nodemon": "^3.1.14",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Request, Response, NextFunction } from 'express';
2+
import { DEVICE_TOKEN_COOKIE_NAME } from '../cookie';
3+
4+
/**
5+
* Test the device token reading functionality in auth middleware.
6+
*
7+
* The actual createAuthenticateMiddleware requires database connections,
8+
* so we test the device token parsing logic in isolation.
9+
*/
10+
11+
/** Cookie parsing function - mirrors the implementation in auth.ts */
12+
const parseCookieToken = (req: Request, cookieName: string): string | undefined => {
13+
const header = req.headers.cookie;
14+
if (!header) return undefined;
15+
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
16+
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
17+
};
18+
19+
describe('auth middleware device token handling', () => {
20+
const createMockRequest = (cookies?: string): Partial<Request> => ({
21+
headers: cookies ? { cookie: cookies } : {},
22+
});
23+
24+
describe('device token cookie parsing', () => {
25+
it('should extract device token from cookie header', () => {
26+
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=device-abc123`);
27+
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
28+
expect(deviceToken).toBe('device-abc123');
29+
});
30+
31+
it('should return undefined when device token cookie is not present', () => {
32+
const req = createMockRequest('other_cookie=value');
33+
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
34+
expect(deviceToken).toBeUndefined();
35+
});
36+
37+
it('should return undefined when no cookies are present', () => {
38+
const req = createMockRequest();
39+
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
40+
expect(deviceToken).toBeUndefined();
41+
});
42+
43+
it('should handle multiple cookies and extract device token', () => {
44+
const req = createMockRequest(
45+
`session=abc; ${DEVICE_TOKEN_COOKIE_NAME}=device-xyz789; csrf=token123`
46+
);
47+
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
48+
expect(deviceToken).toBe('device-xyz789');
49+
});
50+
51+
it('should decode URL-encoded device token values', () => {
52+
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=device%2Ftoken%3D123`);
53+
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
54+
expect(deviceToken).toBe('device/token=123');
55+
});
56+
57+
it('should handle device token with special characters', () => {
58+
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=abc-123_XYZ.test`);
59+
const deviceToken = parseCookieToken(req as Request, DEVICE_TOKEN_COOKIE_NAME);
60+
expect(deviceToken).toBe('abc-123_XYZ.test');
61+
});
62+
});
63+
64+
describe('device token attachment to request', () => {
65+
it('should set deviceToken on request when cookie is present', () => {
66+
const req = createMockRequest(`${DEVICE_TOKEN_COOKIE_NAME}=device-token-value`) as Request;
67+
68+
// Simulate what auth middleware does
69+
const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
70+
if (deviceToken) {
71+
(req as any).deviceToken = deviceToken;
72+
}
73+
74+
expect((req as any).deviceToken).toBe('device-token-value');
75+
});
76+
77+
it('should not set deviceToken when cookie is absent', () => {
78+
const req = createMockRequest('other=value') as Request;
79+
80+
const deviceToken = parseCookieToken(req, DEVICE_TOKEN_COOKIE_NAME);
81+
if (deviceToken) {
82+
(req as any).deviceToken = deviceToken;
83+
}
84+
85+
expect((req as any).deviceToken).toBeUndefined();
86+
});
87+
});
88+
});
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import type { Request, Response } from 'express';
2+
import {
3+
SESSION_COOKIE_NAME,
4+
DEVICE_TOKEN_COOKIE_NAME,
5+
getSessionCookieConfig,
6+
getDeviceTokenCookieConfig,
7+
setSessionCookie,
8+
clearSessionCookie,
9+
setDeviceTokenCookie,
10+
clearDeviceTokenCookie,
11+
parseCookieValue,
12+
getDeviceTokenFromRequest,
13+
getSessionTokenFromRequest,
14+
} from '../cookie';
15+
import type { AuthSettings } from '../../types';
16+
17+
describe('cookie utilities', () => {
18+
describe('getSessionCookieConfig', () => {
19+
it('returns default config when no authSettings provided', () => {
20+
const config = getSessionCookieConfig();
21+
expect(config).toEqual({
22+
secure: false, // NODE_ENV is 'test'
23+
sameSite: 'lax',
24+
domain: undefined,
25+
httpOnly: true,
26+
maxAge: 86400,
27+
path: '/',
28+
});
29+
});
30+
31+
it('uses authSettings values when provided', () => {
32+
const authSettings: AuthSettings = {
33+
cookieSecure: true,
34+
cookieSamesite: 'strict',
35+
cookieDomain: '.example.com',
36+
cookieHttponly: false,
37+
cookieMaxAge: '3600',
38+
cookiePath: '/api',
39+
};
40+
const config = getSessionCookieConfig(authSettings);
41+
expect(config).toEqual({
42+
secure: true,
43+
sameSite: 'strict',
44+
domain: '.example.com',
45+
httpOnly: false,
46+
maxAge: 3600,
47+
path: '/api',
48+
});
49+
});
50+
51+
it('uses rememberMeDuration when rememberMe is true', () => {
52+
const authSettings: AuthSettings = {
53+
cookieMaxAge: '3600',
54+
rememberMeDuration: '2592000', // 30 days
55+
};
56+
const config = getSessionCookieConfig(authSettings, true);
57+
expect(config.maxAge).toBe(2592000);
58+
});
59+
60+
it('uses cookieMaxAge when rememberMe is false', () => {
61+
const authSettings: AuthSettings = {
62+
cookieMaxAge: '3600',
63+
rememberMeDuration: '2592000',
64+
};
65+
const config = getSessionCookieConfig(authSettings, false);
66+
expect(config.maxAge).toBe(3600);
67+
});
68+
69+
it('falls back to cookieMaxAge when rememberMeDuration is not set', () => {
70+
const authSettings: AuthSettings = {
71+
cookieMaxAge: '7200',
72+
};
73+
const config = getSessionCookieConfig(authSettings, true);
74+
expect(config.maxAge).toBe(7200);
75+
});
76+
});
77+
78+
describe('getDeviceTokenCookieConfig', () => {
79+
it('returns config with 90 day maxAge', () => {
80+
const config = getDeviceTokenCookieConfig();
81+
expect(config.maxAge).toBe(90 * 24 * 60 * 60);
82+
expect(config.httpOnly).toBe(true);
83+
});
84+
85+
it('uses authSettings for other cookie options', () => {
86+
const authSettings: AuthSettings = {
87+
cookieSecure: true,
88+
cookieDomain: '.example.com',
89+
};
90+
const config = getDeviceTokenCookieConfig(authSettings);
91+
expect(config.secure).toBe(true);
92+
expect(config.domain).toBe('.example.com');
93+
});
94+
});
95+
96+
describe('setSessionCookie', () => {
97+
it('sets cookie with correct options', () => {
98+
const mockRes = {
99+
cookie: jest.fn(),
100+
} as unknown as Response;
101+
102+
const config = {
103+
secure: true,
104+
sameSite: 'lax' as const,
105+
domain: '.example.com',
106+
httpOnly: true,
107+
maxAge: 3600,
108+
path: '/',
109+
};
110+
111+
setSessionCookie(mockRes, 'test-token', config);
112+
113+
expect(mockRes.cookie).toHaveBeenCalledWith(
114+
SESSION_COOKIE_NAME,
115+
'test-token',
116+
{
117+
secure: true,
118+
sameSite: 'lax',
119+
domain: '.example.com',
120+
httpOnly: true,
121+
maxAge: 3600000, // converted to milliseconds
122+
path: '/',
123+
}
124+
);
125+
});
126+
});
127+
128+
describe('clearSessionCookie', () => {
129+
it('clears cookie with correct options', () => {
130+
const mockRes = {
131+
clearCookie: jest.fn(),
132+
} as unknown as Response;
133+
134+
const config = {
135+
secure: true,
136+
sameSite: 'lax' as const,
137+
domain: '.example.com',
138+
httpOnly: true,
139+
maxAge: 3600,
140+
path: '/',
141+
};
142+
143+
clearSessionCookie(mockRes, config);
144+
145+
expect(mockRes.clearCookie).toHaveBeenCalledWith(
146+
SESSION_COOKIE_NAME,
147+
{
148+
secure: true,
149+
sameSite: 'lax',
150+
domain: '.example.com',
151+
httpOnly: true,
152+
path: '/',
153+
}
154+
);
155+
});
156+
});
157+
158+
describe('setDeviceTokenCookie', () => {
159+
it('sets device token cookie', () => {
160+
const mockRes = {
161+
cookie: jest.fn(),
162+
} as unknown as Response;
163+
164+
const config: Parameters<typeof setDeviceTokenCookie>[2] = {
165+
secure: true,
166+
sameSite: 'lax',
167+
httpOnly: true,
168+
maxAge: 7776000,
169+
path: '/',
170+
};
171+
172+
setDeviceTokenCookie(mockRes, 'device-123', config);
173+
174+
expect(mockRes.cookie).toHaveBeenCalledWith(
175+
DEVICE_TOKEN_COOKIE_NAME,
176+
'device-123',
177+
expect.objectContaining({
178+
maxAge: 7776000000,
179+
})
180+
);
181+
});
182+
});
183+
184+
describe('clearDeviceTokenCookie', () => {
185+
it('clears device token cookie', () => {
186+
const mockRes = {
187+
clearCookie: jest.fn(),
188+
} as unknown as Response;
189+
190+
const config: Parameters<typeof clearDeviceTokenCookie>[1] = {
191+
secure: false,
192+
sameSite: 'lax',
193+
httpOnly: true,
194+
maxAge: 7776000,
195+
path: '/',
196+
};
197+
198+
clearDeviceTokenCookie(mockRes, config);
199+
200+
expect(mockRes.clearCookie).toHaveBeenCalledWith(
201+
DEVICE_TOKEN_COOKIE_NAME,
202+
expect.objectContaining({
203+
httpOnly: true,
204+
path: '/',
205+
})
206+
);
207+
});
208+
});
209+
210+
describe('parseCookieValue', () => {
211+
it('parses cookie value from header', () => {
212+
const mockReq = {
213+
headers: {
214+
cookie: 'foo=bar; constructive_session=test-token; baz=qux',
215+
},
216+
} as unknown as Request;
217+
218+
const value = parseCookieValue(mockReq, 'constructive_session');
219+
expect(value).toBe('test-token');
220+
});
221+
222+
it('returns undefined when cookie not found', () => {
223+
const mockReq = {
224+
headers: {
225+
cookie: 'foo=bar',
226+
},
227+
} as unknown as Request;
228+
229+
const value = parseCookieValue(mockReq, 'constructive_session');
230+
expect(value).toBeUndefined();
231+
});
232+
233+
it('returns undefined when no cookie header', () => {
234+
const mockReq = {
235+
headers: {},
236+
} as unknown as Request;
237+
238+
const value = parseCookieValue(mockReq, 'constructive_session');
239+
expect(value).toBeUndefined();
240+
});
241+
242+
it('decodes URL-encoded cookie values', () => {
243+
const mockReq = {
244+
headers: {
245+
cookie: 'token=hello%20world',
246+
},
247+
} as unknown as Request;
248+
249+
const value = parseCookieValue(mockReq, 'token');
250+
expect(value).toBe('hello world');
251+
});
252+
});
253+
254+
describe('getDeviceTokenFromRequest', () => {
255+
it('extracts device token from cookie', () => {
256+
const mockReq = {
257+
headers: {
258+
cookie: `${DEVICE_TOKEN_COOKIE_NAME}=device-abc123`,
259+
},
260+
} as unknown as Request;
261+
262+
const token = getDeviceTokenFromRequest(mockReq);
263+
expect(token).toBe('device-abc123');
264+
});
265+
});
266+
267+
describe('getSessionTokenFromRequest', () => {
268+
it('extracts session token from cookie', () => {
269+
const mockReq = {
270+
headers: {
271+
cookie: `${SESSION_COOKIE_NAME}=session-xyz789`,
272+
},
273+
} as unknown as Request;
274+
275+
const token = getSessionTokenFromRequest(mockReq);
276+
expect(token).toBe('session-xyz789');
277+
});
278+
});
279+
});

0 commit comments

Comments
 (0)