Skip to content

Commit a4de12a

Browse files
chrisgzfjloh02
andauthored
feat: Update serverless function NUS auth user endpoint with invalid relay state handling (#3644)
* Add user endpoint for NUS auth serverless fns * Allow callback URLs from authorised domains * Fix tests --------- Co-authored-by: Jonathan Loh <[email protected]> Co-authored-by: Jonathan Loh <[email protected]>
1 parent 297ba24 commit a4de12a

File tree

6 files changed

+67
-4
lines changed

6 files changed

+67
-4
lines changed

website/api/nus/auth/login.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { authenticate } from '../../../src/serverless/nus-auth';
1+
import { authenticate, isCallbackUrlValid } from '../../../src/serverless/nus-auth';
22
import {
33
createRouteHandler,
44
defaultFallback,
@@ -8,6 +8,7 @@ import {
88

99
const errors = {
1010
noRelayState: 'ERR_NO_RELAY_STATE',
11+
invalidRelayState: 'ERR_INVALID_RELAY_STATE',
1112
};
1213

1314
const handlePost: Handler = async (req, res) => {
@@ -17,6 +18,9 @@ const handlePost: Handler = async (req, res) => {
1718
throw new Error(errors.noRelayState);
1819
}
1920

21+
if (!isCallbackUrlValid(relayState)) {
22+
throw new Error(errors.invalidRelayState);
23+
}
2024
const userURL = new URL(relayState);
2125
userURL.searchParams.append('token', token);
2226

@@ -26,6 +30,10 @@ const handlePost: Handler = async (req, res) => {
2630
res.json({
2731
message: 'Relay state not found in request',
2832
});
33+
} else if (err.message === errors.invalidRelayState) {
34+
res.json({
35+
message: 'Invalid relay state given. URL must be from a valid domain.',
36+
});
2937
} else {
3038
throw err;
3139
}

website/api/nus/auth/sso.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createLoginURL } from '../../../src/serverless/nus-auth';
1+
import { createLoginURL, isCallbackUrlValid } from '../../../src/serverless/nus-auth';
22
import {
33
createRouteHandler,
44
defaultFallback,
@@ -9,6 +9,7 @@ import {
99

1010
const errors = {
1111
noCallbackUrl: 'ERR_NO_REFERER',
12+
invalidCallbackUrl: 'ERR_INVALID_REFERER',
1213
};
1314

1415
function getCallbackUrl(callback: string | string[] | undefined) {
@@ -24,12 +25,20 @@ const handleGet: Handler = async (req, res) => {
2425
throw new Error(errors.noCallbackUrl);
2526
}
2627

28+
if (!isCallbackUrlValid(callback)) {
29+
throw new Error(errors.invalidCallbackUrl);
30+
}
31+
2732
res.send(createLoginURL(callback));
2833
} catch (err) {
2934
if (err.message === errors.noCallbackUrl) {
3035
res.json({
3136
message: 'Request needs a referer',
3237
});
38+
} else if (err.message === errors.invalidCallbackUrl) {
39+
res.json({
40+
message: 'Invalid referer given. URL must be from a valid domain.',
41+
});
3342
} else {
3443
throw err;
3544
}

website/api/nus/auth/user.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { authenticate } from '../../../src/serverless/nus-auth';
2+
import {
3+
createRouteHandler,
4+
defaultFallback,
5+
defaultRescue,
6+
Handler,
7+
MethodHandlers,
8+
} from '../../../src/serverless/handler';
9+
10+
const handleGet: Handler = async (req, res) => {
11+
const { user } = await authenticate(req);
12+
res.json(user);
13+
};
14+
15+
const methodHandlers: MethodHandlers = {
16+
GET: handleGet,
17+
};
18+
19+
export default createRouteHandler(methodHandlers, defaultFallback, defaultRescue(true));

website/src/serverless/nus-auth.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const errors = {
1515
noTokenSupplied: 'ERR_NO_TOKEN_SUPPLIED',
1616
};
1717

18+
// Domains allowed as callback URLs
19+
const allowedDomains = ['nusmods.com', 'nuscourses.com', 'modsn.us', 'localhost'];
20+
1821
export type User = {
1922
accountName: string;
2023
upn: string;
@@ -45,6 +48,28 @@ export const createLoginURL = (relayState = '') => {
4548
return ssoLoginURL.toString();
4649
};
4750

51+
export const isCallbackUrlValid = (callbackUrl: string): boolean => {
52+
try {
53+
const url = new URL(callbackUrl);
54+
55+
const validMatch = allowedDomains.some(
56+
(allowedDomain) =>
57+
url.hostname.endsWith(`.${allowedDomain}`) || url.hostname === allowedDomain,
58+
);
59+
60+
if (!validMatch) {
61+
// eslint-disable-next-line no-console
62+
console.error('Invalid callback URL given by user:', callbackUrl);
63+
}
64+
65+
return validMatch;
66+
} catch (error) {
67+
// eslint-disable-next-line no-console
68+
console.error('Invalid callback URL:', error);
69+
return false;
70+
}
71+
};
72+
4873
export const authenticate = async (req: Request) => {
4974
const tokenProvided = req.headers.authorization || (req.body && req.body.SAMLResponse);
5075
if (!tokenProvided) {

website/src/views/modules/ModuleArchiveContainer.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen, waitFor } from '@testing-library/react';
1+
import { act, screen, waitFor } from '@testing-library/react';
22
import { Provider } from 'react-redux';
33
import axios, { AxiosError, AxiosHeaders, AxiosResponse } from 'axios';
44

@@ -82,6 +82,7 @@ describe(ModuleArchiveContainerComponent, () => {
8282
test('should show 404 page when the course code does not exist', async () => {
8383
mockAxiosRequest.mockRejectedValue(notFoundError);
8484
make('/archive/CS1234/2017-2018');
85+
await act(() => Promise.resolve()); // Wait for the react to finish updates
8586
expect(
8687
await screen.findByText(/course CS1234 not found/, { exact: false }),
8788
).toBeInTheDocument();

website/src/views/modules/ModulePageContainer.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen, waitFor } from '@testing-library/react';
1+
import { act, screen, waitFor } from '@testing-library/react';
22
import { Provider } from 'react-redux';
33
import axios, { AxiosError, AxiosHeaders, AxiosResponse } from 'axios';
44

@@ -82,6 +82,7 @@ describe(ModulePageContainerComponent, () => {
8282
test('should show 404 page when the module code does not exist', async () => {
8383
mockAxiosRequest.mockRejectedValue(notFoundError);
8484
make('/courses/CS1234');
85+
await act(() => Promise.resolve()); // Wait for the react to finish updates
8586
expect(await screen.findByText(/course CS1234 not found/)).toBeInTheDocument();
8687
});
8788

0 commit comments

Comments
 (0)