Skip to content
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
2 changes: 1 addition & 1 deletion admin-frontend/src/services/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const intercept = apiAxios.interceptors.response.use(
(error) => {
const originalRequest = error.config;
if (error.response.status !== 401) {
return Promise.reject(new Error('AxiosError', { cause: error }));
throw new Error('AxiosError', { cause: error });
}
axios.interceptors.response.eject(intercept);
return new Promise((resolve, reject) => {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/admin-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import { adminAuth } from './v1/services/admin-auth-service';
import { utils } from './v1/services/utils-service';

export const OIDC_AZUREIDIR_CALLBACK_URL = `${config.get('server:adminFrontend')}/admin-api/auth/${OIDC_AZUREIDIR_CALLBACK_NAME}`;

Check notice on line 39 in backend/src/admin-app.ts

View workflow job for this annotation

GitHub Actions / Unit Tests (backend)

Unused export: OIDC_AZUREIDIR_CALLBACK_URL

import { run as startJobs } from './schedulers/run.all';
import adminUserInvitesRoutes from './v1/routes/admin-user-invites-routes';
Expand All @@ -62,7 +62,7 @@
const fileSession = fileSessionStore(session);
const logStream = {
write: (message) => {
logger.info(message);
logger.info(message.trim());
},
};

Expand Down
4 changes: 2 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ const ExtractJwt = passportJWT.ExtractJwt;
const OidcStrategy = passportOIDCKCIdp.Strategy;
const fileSession = fileSessionStore(session);
const logStream = {
write: (message) => {
logger.info(message);
write: (message: string) => {
logger.info(message.trim());
},
};

Expand Down
39 changes: 24 additions & 15 deletions frontend/src/common/__tests__/apiService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { faker } from '@faker-js/faker';
import { DateTimeFormatter, ZonedDateTime, nativeJs } from '@js-joda/core';
import axios, { AxiosError } from 'axios';
import { AxiosError } from 'axios';
import { saveAs } from 'file-saver';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { REPORT_STATUS } from '../../utils/constant';
import ApiService, { ApiServicePrivate } from '../apiService';
import authService from '../authService.js';
import * as AuthStoreComplete from '../../store/modules/auth';

//Mock the interceptor used by the ApiService so it no longer depends on
//HTTP calls to the backend.
Expand Down Expand Up @@ -523,7 +523,6 @@ describe('ApiServicePrivate', () => {
expect(result).toBe(expected);
});
});

describe('responseErrorInterceptor', () => {
describe('when response status is 401', () => {
it('it tries to refresh the token, and then retries the original request (with the new token)', async () => {
Expand All @@ -543,30 +542,40 @@ describe('ApiServicePrivate', () => {
correlationID: faker.string.alpha(50),
};

//mock the request to the backend to refresh the token.
const refreshTokenRequestSpy = vi
.spyOn(authService, 'refreshAuthToken')
.mockResolvedValue(mockRefreshTokenResponse);
// Mock the auth store
const mockAuthStore = {
getJwtToken: vi.fn().mockResolvedValue(undefined),
isAuthenticated: true,
jwtToken: mockRefreshTokenResponse.jwtFrontend,
};
vi.spyOn(AuthStoreComplete, 'authStore').mockReturnValue(mockAuthStore);

//mock the resubmission of the original request (which occurs after
//receiving the new token)
// Mock localStorage
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(
mockRefreshTokenResponse.correlationID,
);

// Spy on the apiAxios instance instead of global axios
const axiosRequestSpy = vi
.spyOn(axios, 'request')
.spyOn(ApiService.apiAxios, 'request')
.mockResolvedValue({});

// Mock processQueue if needed
global.processQueue = vi.fn();

await expect(
ApiServicePrivate.responseErrorInterceptor(mockUnauthorizedError),
).resolves;

//expect a request to refresh the token
expect(refreshTokenRequestSpy).toHaveBeenCalledOnce();
// Verify the auth store's getJwtToken was called
expect(mockAuthStore.getJwtToken).toHaveBeenCalledOnce();

//after token refresh, the original request is resubmitted (but with
//a new token in the authorization header)
// After token refresh, the original request is resubmitted
expect(axiosRequestSpy).toHaveBeenCalledOnce();
const resubmittedRequest: any = axiosRequestSpy.mock.calls[0][0];
const resubmittedRequest = axiosRequestSpy.mock.calls[0][0];
const expectedResubmittedRequest = {
...originalRequest,
authAlreadyRetried: true,
};
expectedResubmittedRequest.headers['Authorization'] =
`Bearer ${mockRefreshTokenResponse.jwtFrontend}`;
Expand Down
28 changes: 20 additions & 8 deletions frontend/src/common/__tests__/authService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import authService from '../authService.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import axios, { AxiosError } from 'axios';
import { mock } from 'node:test';

vi.mock('axios', async () => {
const actual: any = await vi.importActual('axios');
Expand Down Expand Up @@ -43,15 +44,23 @@ describe('Auth Service', () => {
describe('refreshAuthToken', () => {
// check for valid scenario , that api returns token
it('should return token if api respond is OK.', async () => {
const mockData = {
jwtFrontend: '0.eyJpYXQiOjE1MTYyMzkwMjIsImVhdCI6MTUxODAzOTAyMn0.0',
correlationID: 'testCorrelationID',
};
vi.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
jwtFrontend: 'testToken',
correlationID: 'testCorrelationID',
},
data: mockData,
});
const data = await authService.refreshAuthToken('testToken', 'testCorrelationID');
expect(data.jwtFrontend).toBe('testToken');
expect(data.correlationID).toBe('testCorrelationID');
const data = await authService.refreshAuthToken(
'testToken',
'testCorrelationID',
);
const expectedData = {
...mockData,
eat: 1518039022,
iat: 1516239022,
};
expect(data).toEqual(expectedData);
});
it('should return error if api respond contains error.', async () => {
vi.spyOn(axios, 'post').mockResolvedValueOnce({
Expand All @@ -60,7 +69,10 @@ describe('Auth Service', () => {
error_description: 'refresh token expired.',
},
});
const data = await authService.refreshAuthToken('testToken', 'testCorrelationID');
const data = await authService.refreshAuthToken(
'testToken',
'testCorrelationID',
);
expect(data.error).toBe('refresh token expired.');
});
it('should reject if api respond is unauthorized.', async () => {
Expand Down
68 changes: 36 additions & 32 deletions frontend/src/common/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
IAnnouncementSearchResult,
} from '../types/announcements';
import { ApiRoutes } from '../utils/constant';
import AuthService from './authService';
import { authStore } from '../store/modules/auth';
import { IConfigValue, IReport } from './types';

export enum REPORT_FORMATS {
Expand Down Expand Up @@ -46,40 +46,44 @@ function processQueue(error, token = null) {

// Create new non-global axios instance and intercept strategy
const apiAxios = axios.create();
const responseErrorInterceptor = (error) => {

const responseErrorInterceptor = async (error) => {
const originalRequest = error.config;
if (error.response.status !== 401) {
return Promise.reject(new Error('AxiosError', { cause: error }));

// If there's an error, but not authentication related, reject immediately
// If this request has already been retried, don't retry again
if (error.response?.status !== 401 || originalRequest.authAlreadyRetried) {
throw new Error('AxiosError', { cause: error });
}

originalRequest.authAlreadyRetried = true; // Mark as retried to prevent authentication loops

try {
const aStore = authStore();
await aStore.getJwtToken();

if (!aStore.isAuthenticated || !aStore.jwtToken) {
throw new Error('Authentication failed');
}

// The auth store already updates ApiService headers, but we need to update
// the original request headers for the retry
originalRequest.headers['Authorization'] = `Bearer ${aStore.jwtToken}`;
originalRequest.headers['x-correlation-id'] =
localStorage.getItem('correlationID');

processQueue(null, aStore.jwtToken);

// Retry the original request
return apiAxios.request(originalRequest);
} catch (e) {
processQueue(e, null);

globalThis.location.href = '/token-expired';
throw new Error('token expired', { cause: e });
}
axios.interceptors.response.eject(intercept);
return new Promise((resolve, reject) => {
AuthService.refreshAuthToken(
localStorage.getItem('jwtToken'),
localStorage.getItem('correlationID'),
)
.then((response) => {
if (response.jwtFrontend) {
localStorage.setItem('jwtToken', response.jwtFrontend);
localStorage.setItem('correlationID', response.correlationID);
apiAxios.defaults.headers.common['Authorization'] =
`Bearer ${response.jwtFrontend}`;
originalRequest.headers['Authorization'] =
`Bearer ${response.jwtFrontend}`;
apiAxios.defaults.headers.common['x-correlation-id'] =
response.correlationID;
originalRequest.headers['x-correlation-id'] = response.correlationID;
}
processQueue(null, response.jwtFrontend);
resolve(axios.request(originalRequest));
})
.catch((e) => {
processQueue(e, null);
localStorage.removeItem('jwtToken');
globalThis.location.href = '/token-expired';
reject(new Error('token expired', { cause: e }));
});
});
};

const intercept = apiAxios.interceptors.response.use(
(config) => config,
responseErrorInterceptor,
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/common/authService.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import axios from 'axios';
import { AuthRoutes } from '../utils/constant.js';

function parseJwt(token) {
if (!token) return {};
const base64Url = token.split('.')[1];
if (!base64Url) return {};
const base64 = base64Url.replaceAll('-', '+').replaceAll('_', '/');
const jsonPayload = decodeURIComponent(
globalThis
.atob(base64)
.split('')
.map(function (c) {
return '%' + ('00' + c.codePointAt(0).toString(16)).slice(-2);
})
.join(''),
);

return JSON.parse(jsonPayload);
}

export default {
//Retrieves an auth token from the API endpoint
async getAuthToken() {
Expand Down Expand Up @@ -32,9 +50,9 @@ export default {
return { error: response.data.error_description };
}

return response.data;
return { ...response.data, ...parseJwt(response.data.jwtFrontend) };
} catch (e) {
console.log(`Failed to refresh JWT token - ${e}`); // eslint-disable-line no-console
console.log(`Failed to refresh JWT token - ${e}`);
throw e;
}
},
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ export interface IReport {
naics_code: string;
report_status: string;
employee_count_range_id: string;
user_comment: string | null;
data_constraints: string | null;
}
37 changes: 34 additions & 3 deletions frontend/src/components/InputForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ import { NotificationService } from '../common/notificationService';
import { CsvService, IParseSuccessResponse } from '../common/csvService';
import { LocalDate, TemporalAdjusters, DateTimeFormatter } from '@js-joda/core';
import { Locale } from '@js-joda/locale_en';
import { IConfigValue } from '../common/types';
import { IConfigValue, IReport } from '../common/types';
import axios from 'axios';
import { VFileInput } from 'vuetify/components';
import _ from 'lodash';
Expand Down Expand Up @@ -839,7 +839,7 @@ export default {
computed: {
...mapState(useConfigStore, ['config']),
...mapState(useCodeStore, ['employeeCountRanges', 'naicsCodes']),
...mapState(authStore, ['userInfo']),
...mapState(authStore, ['userInfo', 'keepAlive', 'stopKeepAlive']),
...mapState(useReportStepperStore, [
'reportId',
'reportInfo',
Expand Down Expand Up @@ -1004,16 +1004,32 @@ export default {
},
},
async mounted() {
await this.keepAlive();
this.setStage('UPLOAD');
this.loadConfig()?.catch(() => {
NotificationService.pushNotificationError(
'Failed to load application settings. Please reload the page.',
);
});
const saved = sessionStorage.getItem('backupFormDraft');
if (saved) {
sessionStorage.removeItem('backupFormDraft');
try {
const draft = JSON.parse(saved);
this.setReportInfo(draft);
this.initFormInEditMode();
} catch (e) {
console.error('Failed to parse form draft from sessionStorage', e);
return;
}
}
if (this.isEditMode) {
this.initFormInEditMode();
}
},
beforeUnmount() {
this.stopKeepAlive();
},
methods: {
...mapActions(useReportStepperStore, [
'setStage',
Expand Down Expand Up @@ -1180,7 +1196,6 @@ export default {
async submit() {
this.isSubmit = true;
if (!this.formReady) {
console.log(this.companyName, this.companyAddress, this.naicsCode);
throw new Error('Form missing required fields');
}
this.isProcessing = true;
Expand Down Expand Up @@ -1225,6 +1240,22 @@ export default {
throw new Error(DEFAULT_SUBMISSION_ERROR_MESSAGE);
}
} catch (error: any) {
if (submission) {
const backupDraft: Partial<IReport> = {
report_id: submission.id || '',
report_start_date: submission.startDate,
report_end_date: submission.endDate,
reporting_year: submission.reportingYear,
naics_code: submission.naicsCode,
employee_count_range_id: submission.employeeCountRangeId,
user_comment: submission.comments,
data_constraints: submission.dataConstraints,
};
sessionStorage.setItem(
'backupFormDraft',
JSON.stringify(backupDraft),
);
}
// Handle different kinds of error objects by converting them
// into a SubmissionError
this.onSubmitComplete(this.toISubmissionError(error));
Expand Down
Loading
Loading