Skip to content

Commit

Permalink
Merge pull request #1120 from givepraise/1007_handle_refresh_token_in…
Browse files Browse the repository at this point in the history
…_frontend

Refresh accessToken when JWT access token has expired
  • Loading branch information
kristoferlund committed Aug 11, 2023
2 parents df0f9cc + d1cbd83 commit 51812c6
Show file tree
Hide file tree
Showing 12 changed files with 121 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Frontend:** JWT token is now refreshed when it expires. If the refresh token has expired as well, user has to login again. #1120
- **Frontend:** Fix styling bug that caused the login button to be hidden on short screens. #1107

### Changed
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ yarn

### 3. Create Discord Bot

Create and setup the Discord bot. Be sure to take not of ENV variables during setup as these will be needed during the next step. You need to have administrative access to a Discord server in order to create a bot. Creating a server is free, we recommend setting up a personal server to use for testing purposes.
Create and setup the Discord bot. Be sure to take not of ENV variables during setup as these will be needed during the next step. You need to have administrative access to a Discord server in order to create a bot. Creating a server is free, we recommend setting up a personal server to use for testing purposes.

[Create the Praise Discord bot](https://givepraise.xyz/docs/server-setup/create-discord-bot)

Expand All @@ -70,6 +70,12 @@ Run mongo:
yarn mongodb:start
```

Finishing ENV setup

```
yarn run setup
```

### 6. Build and start api backend

Api, discord-bot and frontend can also be started from the Visual Studio Code Launch menu.
Expand Down
7 changes: 7 additions & 0 deletions packages/api/src/auth/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export class AuthGuard implements CanActivate {
return false;
}

isTokenExpiredError(error: any): boolean {
return error.name === 'TokenExpiredError';
}

/**
* Checks for a valid JWT.
*/
Expand Down Expand Up @@ -94,6 +98,9 @@ export class AuthGuard implements CanActivate {
roles: payload.roles,
} as AuthContext;
} catch (e) {
if (this.isTokenExpiredError(e)) {
throw new ApiException(errorMessages.JWT_TOKEN_EXPIRED);
}
return false;
}
return true;
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"analyze": "yarn run build && source-map-explorer --gzip 'build/static/js/*.js'",
"load-env": "env-cmd --silent --no-override -f ../../.env env-cmd --silent --no-override",
"start": "PORT=$(grep FRONTEND_PORT ../../.env | cut -d '=' -f2) TAILWIND_MODE=watch yarn run load-env craco start",
"lint": "eslint . --ext .ts --ext .tsx",
"lint": "eslint . --ext .ts --ext .tsx --fix",
"test": "jest",
"test:watch": "jest --watch"
},
Expand Down
10 changes: 10 additions & 0 deletions packages/frontend/src/model/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ export const AccessToken = selector<string | undefined>({
},
});

export const RefreshToken = selector<string | undefined>({
key: 'RefreshToken',
get: ({ get }) => {
const activeTokenSet = get(ActiveTokenSet);
if (!activeTokenSet) return;

return activeTokenSet.refreshToken;
},
});

export const DecodedAccessToken = selector<JwtTokenData | undefined>({
key: 'DecodedAccessToken',
get: ({ get }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { components } from 'api-types';

export type RefreshTokenInputDto = components['schemas']['GenerateTokenDto'];
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export interface TokenSet {
accessToken: string;
refreshToken: string;
}
2 changes: 1 addition & 1 deletion packages/frontend/src/navigation/MainRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const AuthRoute = ({
) : (
<Redirect
to={{
pathname: '/404',
pathname: '/',
}}
/>
)
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/navigation/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ActiveTokenSet, DecodedAccessToken } from '@/model/auth/auth';
import { MainLayout } from '../layouts/MainLayout';

const ActivatePage = React.lazy(() => import('@/pages/Activate/ActivatePage'));
const ErrorPage = React.lazy(() => import('@/pages/ErrorPage'));
const NotFoundPage = React.lazy(() => import('@/pages/NotFoundPage'));

export const Routes = (): JSX.Element => {
const [tokenSet, setTokenSet] = useRecoilState(ActiveTokenSet);
Expand All @@ -26,7 +26,7 @@ export const Routes = (): JSX.Element => {
<ActivatePage />
</Route>
<Route exact path="/404">
<ErrorPage error={{ message: 'Not found' }} />
<NotFoundPage />
</Route>

<MainLayout />
Expand Down
26 changes: 26 additions & 0 deletions packages/frontend/src/pages/NotFoundPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { faHeartCrack } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Link } from 'react-router-dom';

const NotFoundPage = (): JSX.Element => {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col gap-4 text-2xl text-center">
<FontAwesomeIcon
icon={faHeartCrack}
size="3x"
className="text-themecolor-3"
/>
<div className="font-semibold">
We couldn&apos;t find what you were looking for
</div>
<div>
<Link to="/">Go to start page</Link>
</div>
</div>
</div>
);
};

// eslint-disable-next-line import/no-default-export
export default NotFoundPage;
32 changes: 26 additions & 6 deletions packages/frontend/src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,41 @@ import { ActivateInputDto } from '@/model/activate/dto/activate-input.dto';
import { TokenSet } from '@/model/auth/interfaces/token-set.interface';
import { LoginResponseDto } from '@/model/auth/dto/login-response.dto';
import { NonceResponseDto } from '@/model/auth/dto/nonce-response.dto';
import { RefreshTokenInputDto } from '@/model/auth/dto/refresh-token-input-dto';
import { isResponseOk } from '../model/api';

export const requestApiAuth = async (
params: LoginInputDto
): Promise<TokenSet | undefined> => {
const apiClient = makeApiClient();
const response = await apiClient.post('/auth/eth-signature/login', params);
if (!response) throw Error('Failed to request authorization');
if (isResponseOk<LoginResponseDto>(response)) {
const { accessToken, refreshToken } = response.data;

const { accessToken } = response.data as unknown as LoginResponseDto;
setRecoil(ActiveTokenSet, {
accessToken,
refreshToken,
});

setRecoil(ActiveTokenSet, {
accessToken,
});
return getRecoil(ActiveTokenSet);
}
};

export const requestApiRefreshToken = async (
params: RefreshTokenInputDto
): Promise<TokenSet | undefined> => {
const apiClient = makeApiClient(false);
const response = await apiClient.post('/auth/eth-signature/refresh', params);
if (isResponseOk<LoginResponseDto>(response)) {
const { accessToken, refreshToken } = response.data;

setRecoil(ActiveTokenSet, {
accessToken,
refreshToken,
});

return getRecoil(ActiveTokenSet);
return getRecoil(ActiveTokenSet);
}
};

export const requestNonce = async (
Expand Down
42 changes: 36 additions & 6 deletions packages/frontend/src/utils/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import { toast } from 'react-hot-toast';
import { requestApiRefreshToken } from '@/utils/auth';
import { getRecoil, setRecoil } from 'recoil-nexus';
import { ActiveTokenSet } from '../model/auth/auth';

const isJsonBlob = (data): data is Blob =>
data instanceof Blob && data.type === 'application/json';
Expand All @@ -9,15 +12,42 @@ const isJsonBlob = (data): data is Blob =>
*
* @param err
*/
export const handleErrors = (
err: AxiosError,
export const handleErrors = async (
err: AxiosError<{
code: number;
message: string;
statusCode: number;
}>,
handleErrorsAutomatically = true
): AxiosError => {
): Promise<
AxiosError<{
code: number;
message: string;
statusCode: number;
}>
> => {
// Handling errors automatically means the error will be displayed to the user with a toast.
// If not handled automatically, the error will just be logged to the console and returned.
if (!handleErrorsAutomatically) {
console.error(err);
return err;
throw err;
}

// If the error is a 401 and expired jwt token, try to refresh the token
if (err?.response?.status === 401 && err?.response?.data?.code === 1107) {
// 1107 are the error code for expired jwt token that defined in backend
const tokenSet = getRecoil(ActiveTokenSet);
if (!tokenSet) {
// Unlikely scenario: API returns 401 but no token set is available
return err;
}
try {
await requestApiRefreshToken({ refreshToken: tokenSet.refreshToken });
return err;
} catch (error) {
console.error('Refresh JWT token failed', error);
setRecoil(ActiveTokenSet, undefined);
}
}

if (err?.response) {
Expand All @@ -27,8 +57,8 @@ export const handleErrors = (
const json = JSON.parse(text);
toast.error(json.message);
});
} else if ((err.response.data as Error).message) {
toast.error((err.response.data as Error).message);
} else if (err.response.data.message) {
toast.error(err.response.data.message);
} else {
toast.error('Something went wrong');
}
Expand Down

0 comments on commit 51812c6

Please sign in to comment.