Skip to content

Commit e2651ee

Browse files
CSRF generation
1 parent 660ac15 commit e2651ee

29 files changed

+566
-84
lines changed

amplify.yml

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: 1
22
applications:
33
- appRoot: frontend
4-
backend:
4+
frontend:
55
phases:
66
build:
77
commands:
@@ -10,18 +10,8 @@ applications:
1010
- nvm use 20.13.1
1111
- npm ci --cache .npm --prefer-offline
1212
- npm run create-amplify-outputs env
13-
- cd frontend
14-
frontend:
15-
phases:
16-
build:
17-
commands:
1813
- npm run build
1914
artifacts:
2015
baseDirectory: .next
2116
files:
2217
- '**/*'
23-
cache:
24-
paths:
25-
- frontend/.next/cache/**/*
26-
- .npm/**/*
27-
- node_modules/**/*

frontend/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
"@aws-amplify/backend": "^1.2.0",
2323
"@aws-amplify/ui-react": "^6.3.1",
2424
"@aws-sdk/client-ses": "^3.637.0",
25+
"@edge-csrf/core": "^2.5.2",
26+
"@edge-csrf/nextjs": "^2.5.2",
2527
"aws-amplify": "^6.6.0",
2628
"date-fns": "^4.1.0",
29+
"jwt-decode": "^4.0.0",
2730
"markdown-it": "^13.0.1",
2831
"mimetext": "^3.0.24",
2932
"next": "14.2.13",
@@ -43,6 +46,7 @@
4346
"@testing-library/react": "^16.0.1",
4447
"@testing-library/user-event": "^14.5.2",
4548
"@types/jest": "^29.5.14",
49+
"@types/jsonwebtoken": "^9.0.8",
4650
"@types/markdown-it": "^13.0.1",
4751
"@types/node": "^22.8.1",
4852
"@types/react": "^18",
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { mockDeep } from 'jest-mock-extended';
2+
import {
3+
generateCsrf,
4+
getSessionId,
5+
verifyCsrfToken,
6+
verifyCsrfTokenFull,
7+
getCsrfFormValue,
8+
} from '@utils/csrf-utils';
9+
import { cookies } from 'next/headers';
10+
import { sign } from 'jsonwebtoken';
11+
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies';
12+
import { BinaryLike, BinaryToTextEncoding } from 'node:crypto';
13+
14+
class MockHmac {
15+
constructor() {}
16+
17+
update(_: BinaryLike) {
18+
return this;
19+
}
20+
21+
digest(_: BinaryToTextEncoding) {
22+
return 'hmac';
23+
}
24+
}
25+
26+
const mockJwt = sign(
27+
{
28+
jti: 'jti',
29+
},
30+
'key'
31+
);
32+
33+
jest.mock('next/headers');
34+
jest.mock('node:crypto', () => ({
35+
createHmac: () => new MockHmac(),
36+
randomBytes: () => 'salt',
37+
}));
38+
39+
const OLD_ENV = { ...process.env };
40+
41+
beforeAll(() => {
42+
process.env.CSRF_SECRET = 'secret';
43+
});
44+
45+
beforeEach(() => {
46+
jest.clearAllMocks();
47+
});
48+
49+
afterAll(() => {
50+
process.env = OLD_ENV;
51+
});
52+
53+
describe('getCsrfFormValue', () => {
54+
test('cookie is present', async () => {
55+
jest.mocked(cookies).mockReturnValue(
56+
mockDeep<ReadonlyRequestCookies>({
57+
get: (_: string) => ({
58+
name: 'csrf_token',
59+
value: 'token',
60+
}),
61+
})
62+
);
63+
64+
const csrfToken = await getCsrfFormValue();
65+
66+
expect(csrfToken).toEqual('token');
67+
});
68+
test('cookie is not present', async () => {
69+
jest.mocked(cookies).mockReturnValue(
70+
mockDeep<ReadonlyRequestCookies>({
71+
get: (_: string) => undefined,
72+
})
73+
);
74+
75+
const csrfToken = await getCsrfFormValue();
76+
77+
expect(csrfToken).toEqual('no_token');
78+
});
79+
});
80+
81+
describe('getSessionId', () => {
82+
test('errors when access token not found', async () => {
83+
jest.mocked(cookies).mockReturnValue(
84+
mockDeep<ReadonlyRequestCookies>({
85+
getAll: () => [],
86+
})
87+
);
88+
89+
await expect(() => getSessionId()).rejects.toThrow(
90+
'Could not get access token'
91+
);
92+
});
93+
94+
test('errors when session ID not found', async () => {
95+
const mockEmptyJwt = sign(
96+
{
97+
jti: undefined,
98+
},
99+
'key'
100+
);
101+
102+
jest.mocked(cookies).mockReturnValue(
103+
mockDeep<ReadonlyRequestCookies>({
104+
getAll: () => [
105+
{
106+
name: 'Cognito.123.accessToken',
107+
value: mockEmptyJwt,
108+
},
109+
],
110+
})
111+
);
112+
113+
await expect(() => getSessionId()).rejects.toThrow(
114+
'Could not get session ID'
115+
);
116+
});
117+
118+
test('returns session id', async () => {
119+
jest.mocked(cookies).mockReturnValue(
120+
mockDeep<ReadonlyRequestCookies>({
121+
getAll: () => [
122+
{
123+
name: 'Cognito.123.accessToken',
124+
value: mockJwt,
125+
},
126+
],
127+
})
128+
);
129+
130+
const sessionId = await getSessionId();
131+
132+
expect(sessionId).toEqual('jti');
133+
});
134+
});
135+
136+
test('generateCsrf', async () => {
137+
const csrf = await generateCsrf();
138+
expect(csrf).toEqual('hmac.salt');
139+
});
140+
141+
describe('verifyCsrfToken', () => {
142+
test('valid CSRF token', async () => {
143+
const csrfVerification = await verifyCsrfToken(
144+
'hmac.salt',
145+
'secret',
146+
'salt'
147+
);
148+
149+
expect(csrfVerification).toEqual(true);
150+
});
151+
152+
test('invalid CSRF token', async () => {
153+
const csrfVerification = await verifyCsrfToken(
154+
'hmac2.salt',
155+
'secret',
156+
'salt'
157+
);
158+
159+
expect(csrfVerification).toEqual(false);
160+
});
161+
});
162+
163+
describe('verifyCsrfTokenFull', () => {
164+
test('missing CSRF cookie', async () => {
165+
const formData = mockDeep<FormData>();
166+
167+
jest.mocked(cookies).mockReturnValue(
168+
mockDeep<ReadonlyRequestCookies>({
169+
getAll: () => [
170+
{
171+
name: 'Cognito.123.accessToken',
172+
value: mockJwt,
173+
},
174+
],
175+
})
176+
);
177+
178+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
179+
'missing CSRF cookie'
180+
);
181+
});
182+
183+
test('missing CSRF form field', async () => {
184+
const formData = mockDeep<FormData>({
185+
get: () => null,
186+
});
187+
188+
jest.mocked(cookies).mockReturnValue(
189+
mockDeep<ReadonlyRequestCookies>({
190+
getAll: () => [
191+
{
192+
name: 'Cognito.123.accessToken',
193+
value: mockJwt,
194+
},
195+
],
196+
get: (_: string) => ({
197+
name: 'csrf_token',
198+
value: 'hmac.salt',
199+
}),
200+
})
201+
);
202+
203+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
204+
'missing CSRF form field'
205+
);
206+
});
207+
208+
test('CSRF mismatch', async () => {
209+
const formData = mockDeep<FormData>({
210+
get: () => 'hmac2.salt',
211+
});
212+
213+
jest.mocked(cookies).mockReturnValue(
214+
mockDeep<ReadonlyRequestCookies>({
215+
getAll: () => [
216+
{
217+
name: 'Cognito.123.accessToken',
218+
value: mockJwt,
219+
},
220+
],
221+
get: (_: string) => ({
222+
name: 'csrf_token',
223+
value: 'hmac.salt',
224+
}),
225+
})
226+
);
227+
228+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
229+
'CSRF mismatch'
230+
);
231+
});
232+
233+
test('invalid CSRF form field', async () => {
234+
const formData = mockDeep<FormData>({
235+
get: () => 'hmac2.salt',
236+
});
237+
238+
jest.mocked(cookies).mockReturnValue(
239+
mockDeep<ReadonlyRequestCookies>({
240+
getAll: () => [
241+
{
242+
name: 'Cognito.123.accessToken',
243+
value: mockJwt,
244+
},
245+
],
246+
get: (_: string) => ({
247+
name: 'csrf_token',
248+
value: 'hmac2.salt',
249+
}),
250+
})
251+
);
252+
253+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
254+
'CSRF error'
255+
);
256+
});
257+
258+
test('valid CSRF', async () => {
259+
const formData = mockDeep<FormData>({
260+
get: () => 'hmac.salt',
261+
});
262+
263+
jest.mocked(cookies).mockReturnValue(
264+
mockDeep<ReadonlyRequestCookies>({
265+
getAll: () => [
266+
{
267+
name: 'Cognito.123.accessToken',
268+
value: mockJwt,
269+
},
270+
],
271+
get: (_: string) => ({
272+
name: 'csrf_token',
273+
value: 'hmac.salt',
274+
}),
275+
})
276+
);
277+
278+
const csrfVerification = await verifyCsrfTokenFull(formData);
279+
280+
expect(csrfVerification).toEqual(true);
281+
});
282+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getEnvironmentVariable } from '@utils/get-environment-variable';
2+
3+
test('throws on missing environment variable', () => {
4+
delete process.env.TEST_VAR;
5+
6+
expect(() => getEnvironmentVariable('TEST_VAR')).toThrow(
7+
'Missing environment variable'
8+
);
9+
});
10+
11+
test('returns environment variable value', () => {
12+
process.env.TEST_VAR = 'value';
13+
14+
expect(getEnvironmentVariable('TEST_VAR')).toEqual('value');
15+
});

frontend/src/app/auth/page.dev.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import path from 'path';
1010
import { getBasePath } from '@utils/get-base-path';
1111
import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain';
1212

13-
export const Redirect = () => {
13+
const useRedirectPath = () => {
1414
const searchParams = useSearchParams();
1515

1616
const requestRedirectPath = searchParams.get('redirect');
@@ -27,9 +27,21 @@ export const Redirect = () => {
2727
redirectPath = redirectPath.slice(basePath.length);
2828
}
2929

30+
return redirectPath;
31+
};
32+
33+
export const Redirect = () => {
34+
const redirectPath = useRedirectPath();
35+
3036
return redirect(redirectPath, RedirectType.push);
3137
};
3238

39+
const SignIn = () => {
40+
const redirectPath = useRedirectPath();
41+
42+
redirect(`/auth/signin?redirect=${encodeURIComponent(redirectPath)}`);
43+
};
44+
3345
export default function Page() {
3446
return (
3547
<NHSNotifyMain>
@@ -45,7 +57,7 @@ export default function Page() {
4557
},
4658
}}
4759
>
48-
<Redirect />
60+
<SignIn />
4961
</Authenticator>
5062
</Suspense>
5163
</NHSNotifyMain>

0 commit comments

Comments
 (0)