Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

login page and protected routes #75

Merged
merged 3 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Expand Down Expand Up @@ -120,4 +121,10 @@ export class AuthController {
throw new BadRequestException(e.message);
}
}

@Get('/token/:code')
async grantAccessToken(@Request() req) {
const { code } = req.params;
return await this.authService.tokenExchange(code);
}
}
29 changes: 29 additions & 0 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
CognitoIdentityProviderClient,
ListUsersCommand,
} from '@aws-sdk/client-cognito-identity-provider';
import axios from 'axios';

import CognitoAuthConfig from './aws-exports';
import { SignUpRequestDTO } from './dtos/sign-up.request.dto';
import { SignInRequestDto } from './dtos/sign-in.request.dto';
import { SignInResponseDto } from './dtos/sign-in.response.dto';
import { TokenExchangeResponseDTO } from './dtos/token-exchange.response.dto';

@Injectable()
export class AuthService {
Expand Down Expand Up @@ -173,4 +175,31 @@ export class AuthService {

await this.providerClient.send(adminDeleteUserCommand);
}

/**
* exhanges the authorization code for authorization tokens
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html
*
* @param code - the authorization code granted by Cognito during the user's login
*/
tokenExchange = async (code: string): Promise<string> => {
const body = {
grant_type: 'authorization_code',
code,
client_id: CognitoAuthConfig.clientId,
redirect_uri: `${process.env.NX_CLIENT_URL}/login`,
};

const tokenExchangeEndpoint = `https://${CognitoAuthConfig.clientName}.auth.${CognitoAuthConfig.region}.amazoncognito.com/oauth2/token`;

const urlEncodedBody = new URLSearchParams(body);

const res = await axios
.post(tokenExchangeEndpoint, urlEncodedBody)
.catch((err) => {
throw new Error(`Error while fetching tokens from cognito: ${err}`);
});
const tokens = res.data as TokenExchangeResponseDTO;
return tokens.access_token;
};
}
1 change: 1 addition & 0 deletions apps/backend/src/auth/aws-exports.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const CognitoAuthConfig = {
userPoolId: process.env.NX_COGNITO_USER_POOL_ID,
clientId: process.env.NX_COGNITO_CLIENT_ID,
clientName: process.env.NX_COGNITO_CLIENT_NAME,
region: 'us-east-2',
};

Expand Down
19 changes: 19 additions & 0 deletions apps/backend/src/auth/dtos/token-exchange.response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IsString, IsNotEmpty, IsNumberString } from 'class-validator';

export class TokenExchangeResponseDTO {
@IsNotEmpty()
@IsString()
access_token: string;

@IsString()
refresh_token: string;

@IsString()
id_token: string;

@IsString()
token_type: string;

@IsNumberString()
expires_in: number;
}
13 changes: 12 additions & 1 deletion apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type SubmitReviewRequest = {
};

export class ApiClient {
private axiosInstance: AxiosInstance;
private readonly axiosInstance: AxiosInstance;

constructor() {
this.axiosInstance = axios.create({ baseURL: defaultBaseUrl });
Expand All @@ -26,6 +26,17 @@ export class ApiClient {
return this.get('/api') as Promise<string>;
}

/**
* sends code to backend to get user's access token
*
* @param code - code from cognito oauth
* @returns access token
*/
public async getToken(code: string): Promise<string> {
const token = await this.get(`/api/auth/token/${code}`);
return token as string;
}

public async getAllApplications(
accessToken: string,
): Promise<ApplicationRow[]> {
Expand Down
40 changes: 24 additions & 16 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
import { useEffect } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { useState } from 'react';

import apiClient from '@api/apiClient';
import Root from '@containers/root';
import NotFound from '@containers/404';
import Test from '@containers/test';

const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <NotFound />,
},
{
path: '/test',
element: <Test />,
},
]);
import LoginContext from '@components/LoginPage/LoginContext';
import ProtectedRoutes from '@components/ProtectedRoutes';
import LoginPage from '@components/LoginPage';

export const App: React.FC = () => {
return <RouterProvider router={router} />;
const [token, setToken] = useState<string>('');
return (
<LoginContext.Provider value={{ setToken, token }}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />

{/* Protected Routes */}
<Route element={<ProtectedRoutes token={token} />}>
<Route path="/" element={<Root />} />
<Route path="/test" element={<Test />} />
</Route>

{/* 404 Route */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</LoginContext.Provider>
);
};

export default App;
23 changes: 8 additions & 15 deletions apps/frontend/src/components/ApplicationTables/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ApplicationRow, Application, Semester } from '../types';
import apiClient from '@api/apiClient';
import { applicationColumns } from './columns';
import { ReviewModal } from './reviewModal';
import useLoginContext from '@components/LoginPage/useLoginContext';

const TODAY = new Date();

Expand All @@ -34,12 +35,10 @@ const getCurrentYear = (): number => {
export function ApplicationTable() {
const isPageRendered = useRef<boolean>(false);

// TODO switch to use code grant flow
// TODO automatically redirect to login page if not logged in
const { token: accessToken } = useLoginContext();
// TODO implement auto token refresh
const [data, setData] = useState<ApplicationRow[]>([]);
const [fullName, setFullName] = useState<string>('');
const [accessToken, setAccessToken] = useState<string>('');
const [rowSelection, setRowSelection] = useState<GridRowSelectionModel>([]);
const [selectedUserRow, setSelectedUserRow] = useState<ApplicationRow | null>(
null,
Expand Down Expand Up @@ -78,16 +77,6 @@ export function ApplicationTable() {
setFullName(await apiClient.getFullName(accessToken));
};

useEffect(() => {
// Access token comes from OAuth redirect uri https://frontend.com/#access_token=access_token
const urlParams = new URLSearchParams(window.location.hash.substring(1));
const accessTokenMatch = urlParams.get('access_token');
if (accessTokenMatch) {
setAccessToken(accessTokenMatch);
}
isPageRendered.current = false;
}, []);

useEffect(() => {
if (isPageRendered.current) {
fetchData();
Expand Down Expand Up @@ -135,6 +124,8 @@ export function ApplicationTable() {
? `Selected Applicant: ${selectedUserRow.firstName} ${selectedUserRow.lastName}`
: 'No Applicant Selected'}
</Typography>

{/* TODO refactor application details into a separate component */}
{selectedApplication ? (
<>
<Typography variant="h6" mt={2}>
Expand Down Expand Up @@ -176,8 +167,10 @@ export function ApplicationTable() {
</ListItem>
))}
</List>

{/* TODO refactor reviews into a separate component */}
<Stack>
<Typography variant="body1">
<Stack>
Reviews:
{selectedApplication.reviews.map((review, index) => {
return (
Expand All @@ -194,7 +187,7 @@ export function ApplicationTable() {
</Stack>
);
})}
</Typography>
</Stack>
<Button
variant="contained"
size="small"
Expand Down
10 changes: 10 additions & 0 deletions apps/frontend/src/components/LoginPage/LoginContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createContext } from 'react';

export interface LoginContextType {
setToken: (token: string) => void;
token: string;
}

// Login Context is used to store user's access token
const LoginContext = createContext<LoginContextType | null>(null);
export default LoginContext;
48 changes: 48 additions & 0 deletions apps/frontend/src/components/LoginPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import apiClient from '@api/apiClient';
import useLoginContext from './useLoginContext';
import { useNavigate } from 'react-router-dom';
import { Button, Stack } from '@mui/material';

/**
* Login Page component first checks if the user has been redirected from the
* Cognito login page with an authorization code. If the code is present, it
* fetches the user's access token and stores it in the context.
*/
export default function LoginPage() {
const { setToken } = useLoginContext();
const navigate = useNavigate();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const authCode = urlParams.get('code');

async function getToken() {
if (authCode) {
try {
const token = await apiClient.getToken(authCode);
setToken(token);
navigate('/');
} catch (error) {
console.error('Error fetching token:', error);
}
}
}
getToken();
}, [navigate, setToken]);
return (
<Stack
width="100vw"
height="100vh"
justifyContent="center"
alignItems="center"
>
<Button
variant="contained"
color="primary"
href="https://scaffolding.auth.us-east-2.amazoncognito.com/login?client_id=4c5b8m6tno9fvljmseqgmk82fv&response_type=code&scope=email+openid&redirect_uri=http%3A%2F%2Flocalhost%3A4200%2Flogin"
>
Login
</Button>
</Stack>
);
}
21 changes: 21 additions & 0 deletions apps/frontend/src/components/LoginPage/useLoginContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useContext } from 'react';
import LoginContext, { LoginContextType } from './LoginContext';

/**
* Custom hook to access the LoginContext.
*
* @throws It will throw an error if the `LoginContext` is null.
*
* @returns context - the context value for managing login state
*/
const useLoginContext = (): LoginContextType => {
const context = useContext(LoginContext);

if (context === null) {
throw new Error('Login context is null.');
}

return context;
};

export default useLoginContext;
12 changes: 12 additions & 0 deletions apps/frontend/src/components/ProtectedRoutes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Navigate, Outlet } from 'react-router-dom';

/**
* ProtectedRoutes renders the children components only
* if the user is authenticated (i.e if an access token exists).
* If the user is not authenticated, it redirects to the login page.
*/
function ProtectedRoutes({ token }: { token: string }) {
return token ? <Outlet /> : <Navigate to="/login" />;
}

export default ProtectedRoutes;
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@types/jest": "^29.4.0",
"@types/lodash": "^4.14.202",
"@types/node": "^18.14.2",
"@types/passport-jwt": "^4.0.1",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^6.7.0",
Expand Down
30 changes: 30 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3611,6 +3611,13 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==

"@types/jsonwebtoken@*":
version "9.0.7"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz#e49b96c2b29356ed462e9708fc73b833014727d2"
integrity sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==
dependencies:
"@types/node" "*"

"@types/jsonwebtoken@^9.0.2":
version "9.0.4"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.4.tgz#8b74bbe87bde81a3469d4b32a80609bec62c23ec"
Expand Down Expand Up @@ -3666,6 +3673,29 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.1.tgz#27f7559836ad796cea31acb63163b203756a5b4e"
integrity sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng==

"@types/passport-jwt@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz#080fbe934fb9f6954fb88ec4cdf4bb2cc7c4d435"
integrity sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==
dependencies:
"@types/jsonwebtoken" "*"
"@types/passport-strategy" "*"

"@types/passport-strategy@*":
version "0.2.38"
resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3"
integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==
dependencies:
"@types/express" "*"
"@types/passport" "*"

"@types/passport@*":
version "1.0.17"
resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6"
integrity sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==
dependencies:
"@types/express" "*"

"@types/prop-types@*":
version "15.7.9"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.9.tgz#b6f785caa7ea1fe4414d9df42ee0ab67f23d8a6d"
Expand Down
Loading