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

[Auth] Redirect user to the home page instead of a 500 page if their auth tokens fail to be refreshed #1118

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
143 changes: 17 additions & 126 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import { useQuery } from '@tanstack/react-query';
import { MarkdownText } from 'MarkdownTest';
import { fetchUserProfile } from 'api/requests';
import useSblAuth from 'api/useSblAuth';
import FooterCfGovWrapper from 'components/FooterCfGovWrapper';
import { LoadingApp, LoadingContent } from 'components/Loading';
import ScrollToTop from 'components/ScrollToTop';
import { Alert, PageHeader, SkipNav } from 'design-system-react';
import 'design-system-react/style.css';
import Error500 from 'pages/Error/Error500';
import { NotFound404 } from 'pages/Error/NotFound404';
Expand All @@ -25,25 +23,17 @@ import CreateProfileFormWAssoc from 'pages/ProfileForm/Step1Form/Step1Form';
import { SummaryRoutesList } from 'pages/Summary/SummaryRoutes';
import type { ReactElement } from 'react';
import { Suspense, lazy } from 'react';
import {
BrowserRouter,
Navigate,
Outlet,
Route,
Routes,
useLocation,
} from 'react-router-dom';
import type { UserProfileType } from 'types/filingTypes';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import getIsRoutingEnabled, {
setIsRoutingEnabled,
toggleRouting,
} from 'utils/getIsRoutingEnabled';
import { useHeaderAuthLinks } from 'utils/useHeaderAuthLinks';

import ErrorFallback from 'ErrorFallback';
// eslint-disable-next-line import/no-extraneous-dependencies
import { ErrorBoundary } from 'react-error-boundary';
import release from './constants/release.json';
import BasicLayout from 'BasicLayout';
import ProtectedRoute from 'ProtectedRoute';

const FilingHome = lazy(async () => import('pages/Filing/FilingHome'));
const ProfileForm = lazy(async () => import('pages/ProfileForm'));
Expand Down Expand Up @@ -73,129 +63,30 @@ if (import.meta.env.DEV) {
}
if (!isRoutingEnabled) console.warn('Routing is disabled!');

function BasicLayout(): ReactElement {
const headerLinks = [...useHeaderAuthLinks()];
const location = useLocation();
const auth = useSblAuth();

if (auth.error) {
const errorMessage = auth.error.message;

// Authentication service down
if (errorMessage.includes('Failed to fetch')) {
if (!location.pathname.includes('/500')) {
return (
<Navigate
to='/500'
state={{ message: 'The authentication service is unreachable.' }}
/>
);
}
}
// User's session has expired
else if (
errorMessage.includes("Session doesn't have required client") &&
location.pathname !== '/'
) {
void auth.onLogout();
return <></>;
}
}

return (
<div className='flex flex-col bg-white'>
<div>
<SkipNav />
{/* TODO: Move this component to the DSR for other teams' use */}
{/* See: https://github.com/cfpb/design-system-react/issues/352 */}
<div className='o-banner pl-[0.9375rem] pr-[0.9375rem]'>
<div className='wrapper wrapper__match-content'>
<Alert
message='This is a beta for the Small Business Lending Data Filing Platform'
status='warning'
/>
</div>
</div>
<PageHeader links={headerLinks} />
<Outlet />
</div>
<div>
{/* Part of fix to the white space below the footer problem */}
<FooterCfGovWrapper />
<div className='mx-auto mt-[-30px] max-w-[1200px] px-[30px] pb-5'>
{release.version}
</div>
</div>
</div>
);
}

interface ProtectedRouteProperties {
isAnyAuthorizationLoading: boolean;
isAuthenticated: boolean;
isLoading: boolean;
onLogin: () => Promise<void>;
UserProfile: UserProfileType | undefined;
children: JSX.Element;
}

function ProtectedRoute({
isAnyAuthorizationLoading,
isAuthenticated,
isLoading: isInitialAuthorizationLoading,
onLogin,
UserProfile,
children,
}: ProtectedRouteProperties): JSX.Element {
const { pathname } = useLocation();
const isProfileFormPath = pathname.includes('/profile/complete');

if (!isRoutingEnabled) {
return children;
}

if (!isInitialAuthorizationLoading && !isAuthenticated) {
void onLogin();
return <></>;
}

if (isAnyAuthorizationLoading) return <LoadingContent />;

if (!UserProfile) {
throw new Error('User Profile does not exist');
}

const isUserAssociatedWithAnyInstitution =
(UserProfile?.institutions?.length ?? 0) > 0;
if (!isUserAssociatedWithAnyInstitution && !isProfileFormPath)
return <Navigate replace to='/profile/complete' />;
if (isProfileFormPath && isUserAssociatedWithAnyInstitution)
return <Navigate replace to='/landing' />;
return children;
}

export default function App(): ReactElement {
const auth = useSblAuth();
const {
isAuthenticated: userIsAuthenticated,
isLoading: isAuthLoading,
emailAddress,
} = auth;

const { isLoading: isFetchUserProfileLoading, data: UserProfile } = useQuery({
queryKey: ['fetch-user-profile', emailAddress],
const { isLoading, data: UserProfile } = useQuery({
queryKey: ['fetch-user-profile', auth.emailAddress],
queryFn: async () => fetchUserProfile(auth),
enabled: !!userIsAuthenticated,
enabled: !!auth?.isAuthenticated,
});

const loadingStates = [isAuthLoading, isFetchUserProfileLoading];
const isAnyAuthorizationLoading = loadingStates.some(Boolean);
const ProtectedRouteAuthorizations = {
...auth,
isLoading,
UserProfile,
isAnyAuthorizationLoading,
};

// TODO: Future, use this to configure a logout countdown notice and confirmation
// React.useEffect(() => {
// auth.events.addAccessTokenExpiring(props => {
// console.log('Expiring:', props);
// });
// auth.events.addAccessTokenExpired(props => {
// console.log('Expired:', props);
// })
// }, []);

return (
<BrowserRouter>
<ErrorBoundary FallbackComponent={ErrorFallback}>
Expand Down
65 changes: 65 additions & 0 deletions src/BasicLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import useSblAuth from 'api/useSblAuth';
import FooterCfGovWrapper from 'components/FooterCfGovWrapper';
import { Alert, PageHeader, SkipNav } from 'design-system-react';
import type { ReactElement } from 'react';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useHeaderAuthLinks } from 'utils/useHeaderAuthLinks';

import release from './constants/release.json';

export default function BasicLayout(): ReactElement {
const headerLinks = [...useHeaderAuthLinks()];
const location = useLocation();
const auth = useSblAuth();

if (auth.error) {
const errorMessage = auth.error.message;

// Authentication service down
if (errorMessage.includes('Failed to fetch')) {
if (!location.pathname.includes('/500')) {
return (
<Navigate
to='/500'
state={{ message: 'The authentication service is unreachable.' }}
/>
);
}
}
// User's session has expired
else if (
errorMessage.includes("Session doesn't have required client") &&
location.pathname !== '/'
) {
void auth.onLogout();
return <></>;
}
}

return (
<div className='flex flex-col bg-white'>
<div>
<SkipNav />
{/* TODO: Move this component to the DSR for other teams' use */}
{/* See: https://github.com/cfpb/design-system-react/issues/352 */}
<div className='o-banner pl-[0.9375rem] pr-[0.9375rem]'>
<div className='wrapper wrapper__match-content'>
<Alert
message='This is a beta for the Small Business Lending Data Filing Platform'
status='warning'
/>
</div>
</div>
<PageHeader links={headerLinks} />
<Outlet />
</div>
<div>
{/* Part of fix to the white space below the footer problem */}
<FooterCfGovWrapper />
<div className='mx-auto mt-[-30px] max-w-[1200px] px-[30px] pb-5'>
{release.version}
</div>
</div>
</div>
);
}
46 changes: 46 additions & 0 deletions src/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import useSblAuth from 'api/useSblAuth';
import { LoadingContent } from 'components/Loading';
import { Navigate, useLocation } from 'react-router-dom';
import type { UserProfileType } from 'types/filingTypes';
import getIsRoutingEnabled from 'utils/getIsRoutingEnabled';

const isRoutingEnabled = getIsRoutingEnabled();

interface ProtectedRouteProperties {
isLoading: boolean;
UserProfile: UserProfileType | undefined;
children: JSX.Element;
}

export default function ProtectedRoute({
isLoading,
UserProfile,
children,
}: ProtectedRouteProperties): JSX.Element {
const auth = useSblAuth();
const { pathname } = useLocation();
const isProfileFormPath = pathname.includes('/profile/complete');

if (!isRoutingEnabled) {
return children;
}

if (!auth.isLoading && !auth.isAuthenticated) {
void auth.onLogin();
return <></>;
}

if (auth.isLoading || isLoading) return <LoadingContent />;

if (!UserProfile) {
throw new Error('User Profile does not exist');
}

const isUserAssociatedWithAnyInstitution =
(UserProfile?.institutions?.length ?? 0) > 0;
if (!isUserAssociatedWithAnyInstitution && !isProfileFormPath)
return <Navigate replace to='/profile/complete' />;
if (isProfileFormPath && isUserAssociatedWithAnyInstitution)
return <Navigate replace to='/landing' />;
return children;
}
28 changes: 19 additions & 9 deletions src/api/axiosService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,24 @@ export const getAxiosInstance = (baseUrl = ''): AxiosInstanceExtended => {
'Content-Type': 'application/json',
},
});

newAxiosInstance.interceptors.request.use(response => {
const token = getOidcTokenOutsideOfContext();
if (!token) return response;
response.headers.Authorization = token;
axios.defaults.headers.common.Authorization = token;
return response;
});
newAxiosInstance.interceptors.request.use(
request => {
const token = getOidcTokenOutsideOfContext();
if (!token) return request;
request.headers.Authorization = token;
axios.defaults.headers.common.Authorization = token;
return request;
},
error => {
throw error;
},
);
newAxiosInstance.interceptors.response.use(
response => response,
error => {
throw error;
},
);
return newAxiosInstance;
};

Expand Down Expand Up @@ -73,5 +83,5 @@ export const request = async <D = undefined, T = unknown>({
// @ts-expect-error: A spread argument must either have a tuple type or be passed to a rest parameter.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const response = await axiosInstance[method]<D>(...argumentList);
return response.data as unknown as T;
return response?.data as unknown as T;
};
24 changes: 23 additions & 1 deletion src/api/useSblAuth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import type { AuthContextProps } from 'react-oidc-context';
import { useAuth } from 'react-oidc-context';
import { One } from 'utils/constants';
import { One, Thousand, Three } from 'utils/constants';
import { LOGOUT_REDIRECT_URL } from './common';

const tokenExpiresIn = (token: string | undefined): number | undefined => {
if (!token) return undefined;
const parts = token.split('.');
if (parts.length !== Three) return undefined;
const part = atob(parts[One]);
if (!part) return undefined;
const parsed = JSON.parse(part) as { exp: number };
if (!parsed) return undefined;
const expires = parsed.exp;
if (!expires) return undefined;
return (new Date(expires * Thousand).getTime() - Date.now()) / Thousand;
};

export interface SblAuthProperties extends AuthContextProps {
onLogin: () => Promise<void>;
onLogout: () => Promise<void>;
Expand Down Expand Up @@ -33,6 +46,15 @@ const useSblAuth = (): SblAuthProperties => {
window.logout = onLogout;
}

if (!auth.isLoading) {
const accessUntil = tokenExpiresIn(auth?.user?.access_token);
const refreshUntil = tokenExpiresIn(auth?.user?.refresh_token);

if (accessUntil !== undefined && (!refreshUntil || refreshUntil < 0)) {
void onLogout();
}
}

return {
...auth,
onLogin,
Expand Down
1 change: 1 addition & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const Zero = 0;
export const NegativeOne = -1;
export const One = 1;
export const Two = 2;
export const Three = 3;
export const MAX_RETRIES = 3;
export const UPLOAD_SUBMIT_MAX_RETRIES = 2;
export const Five = 5;
Expand Down
Loading