Skip to content

Commit 22483ef

Browse files
Merge pull request #294 from NHSDigital/feature/CCM-8477_csrf-token
CCM-8446: CSRF generation
2 parents 660ac15 + 4099b7c commit 22483ef

32 files changed

+515
-87
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: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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+
import { getAccessTokenServer } from '@utils/amplify-utils';
14+
15+
class MockHmac {
16+
constructor() {}
17+
18+
update(_: BinaryLike) {
19+
return this;
20+
}
21+
22+
digest(_: BinaryToTextEncoding) {
23+
return 'hmac';
24+
}
25+
}
26+
27+
const mockJwt = sign(
28+
{
29+
jti: 'jti',
30+
},
31+
'key'
32+
);
33+
34+
jest.mock('next/headers');
35+
jest.mock('node:crypto', () => ({
36+
createHmac: () => new MockHmac(),
37+
randomBytes: () => 'salt',
38+
}));
39+
jest.mock('@utils/amplify-utils');
40+
41+
const OLD_ENV = { ...process.env };
42+
43+
beforeAll(() => {
44+
process.env.CSRF_SECRET = 'secret';
45+
});
46+
47+
beforeEach(() => {
48+
jest.clearAllMocks();
49+
});
50+
51+
afterAll(() => {
52+
process.env = OLD_ENV;
53+
});
54+
55+
describe('getCsrfFormValue', () => {
56+
test('cookie is present', async () => {
57+
jest.mocked(cookies).mockReturnValue(
58+
mockDeep<ReadonlyRequestCookies>({
59+
get: (_: string) => ({
60+
name: 'csrf_token',
61+
value: 'token',
62+
}),
63+
})
64+
);
65+
66+
const csrfToken = await getCsrfFormValue();
67+
68+
expect(csrfToken).toEqual('token');
69+
});
70+
test('cookie is not present', async () => {
71+
jest.mocked(cookies).mockReturnValue(
72+
mockDeep<ReadonlyRequestCookies>({
73+
get: (_: string) => undefined,
74+
})
75+
);
76+
77+
const csrfToken = await getCsrfFormValue();
78+
79+
expect(csrfToken).toEqual('no_token');
80+
});
81+
});
82+
83+
describe('getSessionId', () => {
84+
test('errors when access token not found', async () => {
85+
jest.mocked(getAccessTokenServer).mockResolvedValue(undefined);
86+
87+
await expect(() => getSessionId()).rejects.toThrow(
88+
'Could not get access token'
89+
);
90+
});
91+
92+
test('errors when session ID not found', async () => {
93+
const mockEmptyJwt = sign(
94+
{
95+
jti: undefined,
96+
},
97+
'key'
98+
);
99+
100+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockEmptyJwt);
101+
102+
await expect(() => getSessionId()).rejects.toThrow(
103+
'Could not get session ID'
104+
);
105+
});
106+
107+
test('returns session id', async () => {
108+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockJwt);
109+
110+
const sessionId = await getSessionId();
111+
112+
expect(sessionId).toEqual('jti');
113+
});
114+
});
115+
116+
test('generateCsrf', async () => {
117+
const csrf = await generateCsrf();
118+
expect(csrf).toEqual('hmac.salt');
119+
});
120+
121+
describe('verifyCsrfToken', () => {
122+
test('valid CSRF token', async () => {
123+
const csrfVerification = await verifyCsrfToken(
124+
'hmac.salt',
125+
'secret',
126+
'salt'
127+
);
128+
129+
expect(csrfVerification).toEqual(true);
130+
});
131+
132+
test('invalid CSRF token', async () => {
133+
const csrfVerification = await verifyCsrfToken(
134+
'hmac2.salt',
135+
'secret',
136+
'salt'
137+
);
138+
139+
expect(csrfVerification).toEqual(false);
140+
});
141+
});
142+
143+
describe('verifyCsrfTokenFull', () => {
144+
test('missing CSRF cookie', async () => {
145+
const formData = mockDeep<FormData>();
146+
147+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockJwt);
148+
149+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
150+
'missing CSRF cookie'
151+
);
152+
});
153+
154+
test('missing CSRF form field', async () => {
155+
const formData = mockDeep<FormData>({
156+
get: () => null,
157+
});
158+
159+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockJwt);
160+
jest.mocked(cookies).mockReturnValue(
161+
mockDeep<ReadonlyRequestCookies>({
162+
get: (_: string) => ({
163+
name: 'csrf_token',
164+
value: 'hmac.salt',
165+
}),
166+
})
167+
);
168+
169+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
170+
'missing CSRF form field'
171+
);
172+
});
173+
174+
test('CSRF mismatch', async () => {
175+
const formData = mockDeep<FormData>({
176+
get: () => 'hmac2.salt',
177+
});
178+
179+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockJwt);
180+
jest.mocked(cookies).mockReturnValue(
181+
mockDeep<ReadonlyRequestCookies>({
182+
get: (_: string) => ({
183+
name: 'csrf_token',
184+
value: 'hmac.salt',
185+
}),
186+
})
187+
);
188+
189+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
190+
'CSRF mismatch'
191+
);
192+
});
193+
194+
test('invalid CSRF form field', async () => {
195+
const formData = mockDeep<FormData>({
196+
get: () => 'hmac2.salt',
197+
});
198+
199+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockJwt);
200+
jest.mocked(cookies).mockReturnValue(
201+
mockDeep<ReadonlyRequestCookies>({
202+
get: (_: string) => ({
203+
name: 'csrf_token',
204+
value: 'hmac2.salt',
205+
}),
206+
})
207+
);
208+
209+
await expect(() => verifyCsrfTokenFull(formData)).rejects.toThrow(
210+
'CSRF error'
211+
);
212+
});
213+
214+
test('valid CSRF', async () => {
215+
const formData = mockDeep<FormData>({
216+
get: () => 'hmac.salt',
217+
});
218+
219+
jest.mocked(getAccessTokenServer).mockResolvedValue(mockJwt);
220+
jest.mocked(cookies).mockReturnValue(
221+
mockDeep<ReadonlyRequestCookies>({
222+
get: (_: string) => ({
223+
name: 'csrf_token',
224+
value: 'hmac.salt',
225+
}),
226+
})
227+
);
228+
229+
const csrfVerification = await verifyCsrfTokenFull(formData);
230+
231+
expect(csrfVerification).toEqual(true);
232+
});
233+
});
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>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use server';
2+
3+
import { generateCsrf } from '@utils/csrf-utils';
4+
import { cookies } from 'next/headers';
5+
import { getBasePath } from '@utils/get-base-path';
6+
7+
export const GET = async (request: Request) => {
8+
const redirectPath = new URL(request.url).searchParams.get('redirect') ?? '/';
9+
10+
const csrfToken = await generateCsrf();
11+
12+
const resJson = { csrfToken };
13+
14+
cookies().set('csrf_token', csrfToken);
15+
16+
return Response.json(resJson, {
17+
status: 302,
18+
headers: {
19+
Location: `${getBasePath()}${redirectPath}`,
20+
},
21+
});
22+
};

frontend/src/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export async function middleware(request: NextRequest) {
4040
const requestHeaders = new Headers(request.headers);
4141
requestHeaders.set('Content-Security-Policy', csp);
4242

43-
const publicPaths = ['/create-and-submit-templates', '/auth'];
43+
const publicPaths = ['/create-and-submit-templates', '/auth', '/lib'];
4444

4545
if (isPublicPath(request.nextUrl.pathname, publicPaths)) {
4646
const publicPathResponse = NextResponse.next({

0 commit comments

Comments
 (0)