Skip to content

Commit

Permalink
login page and protected routes (#75)
Browse files Browse the repository at this point in the history
* migrate from cognito implicit grant to code grant flow

* implement login page and protected routes

* slightly better login page
  • Loading branch information
ojn03 authored Jan 13, 2025
1 parent 89d19a3 commit 8816be4
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 32 deletions.
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

0 comments on commit 8816be4

Please sign in to comment.