Skip to content

Commit d37f32a

Browse files
authored
feat: GEO-1340 Prevent session timeout on Form page with activity tracking (#1037)
1 parent b0dd173 commit d37f32a

File tree

13 files changed

+645
-133
lines changed

13 files changed

+645
-133
lines changed

admin-frontend/src/services/apiService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const intercept = apiAxios.interceptors.response.use(
4848
(error) => {
4949
const originalRequest = error.config;
5050
if (error.response.status !== 401) {
51-
return Promise.reject(new Error('AxiosError', { cause: error }));
51+
throw new Error('AxiosError', { cause: error });
5252
}
5353
axios.interceptors.response.eject(intercept);
5454
return new Promise((resolve, reject) => {

backend/src/admin-app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const OidcStrategy = passportOIDCKCIdp.Strategy;
6262
const fileSession = fileSessionStore(session);
6363
const logStream = {
6464
write: (message) => {
65-
logger.info(message);
65+
logger.info(message.trim());
6666
},
6767
};
6868

backend/src/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ const ExtractJwt = passportJWT.ExtractJwt;
7777
const OidcStrategy = passportOIDCKCIdp.Strategy;
7878
const fileSession = fileSessionStore(session);
7979
const logStream = {
80-
write: (message) => {
81-
logger.info(message);
80+
write: (message: string) => {
81+
logger.info(message.trim());
8282
},
8383
};
8484

frontend/src/common/__tests__/apiService.spec.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { faker } from '@faker-js/faker';
22
import { DateTimeFormatter, ZonedDateTime, nativeJs } from '@js-joda/core';
3-
import axios, { AxiosError } from 'axios';
3+
import { AxiosError } from 'axios';
44
import { saveAs } from 'file-saver';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import { REPORT_STATUS } from '../../utils/constant';
77
import ApiService, { ApiServicePrivate } from '../apiService';
8-
import authService from '../authService.js';
8+
import * as AuthStoreComplete from '../../store/modules/auth';
99

1010
//Mock the interceptor used by the ApiService so it no longer depends on
1111
//HTTP calls to the backend.
@@ -523,7 +523,6 @@ describe('ApiServicePrivate', () => {
523523
expect(result).toBe(expected);
524524
});
525525
});
526-
527526
describe('responseErrorInterceptor', () => {
528527
describe('when response status is 401', () => {
529528
it('it tries to refresh the token, and then retries the original request (with the new token)', async () => {
@@ -543,30 +542,40 @@ describe('ApiServicePrivate', () => {
543542
correlationID: faker.string.alpha(50),
544543
};
545544

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

551-
//mock the resubmission of the original request (which occurs after
552-
//receiving the new token)
553+
// Mock localStorage
554+
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(
555+
mockRefreshTokenResponse.correlationID,
556+
);
557+
558+
// Spy on the apiAxios instance instead of global axios
553559
const axiosRequestSpy = vi
554-
.spyOn(axios, 'request')
560+
.spyOn(ApiService.apiAxios, 'request')
555561
.mockResolvedValue({});
556562

563+
// Mock processQueue if needed
564+
global.processQueue = vi.fn();
565+
557566
await expect(
558567
ApiServicePrivate.responseErrorInterceptor(mockUnauthorizedError),
559568
).resolves;
560569

561-
//expect a request to refresh the token
562-
expect(refreshTokenRequestSpy).toHaveBeenCalledOnce();
570+
// Verify the auth store's getJwtToken was called
571+
expect(mockAuthStore.getJwtToken).toHaveBeenCalledOnce();
563572

564-
//after token refresh, the original request is resubmitted (but with
565-
//a new token in the authorization header)
573+
// After token refresh, the original request is resubmitted
566574
expect(axiosRequestSpy).toHaveBeenCalledOnce();
567-
const resubmittedRequest: any = axiosRequestSpy.mock.calls[0][0];
575+
const resubmittedRequest = axiosRequestSpy.mock.calls[0][0];
568576
const expectedResubmittedRequest = {
569577
...originalRequest,
578+
authAlreadyRetried: true,
570579
};
571580
expectedResubmittedRequest.headers['Authorization'] =
572581
`Bearer ${mockRefreshTokenResponse.jwtFrontend}`;

frontend/src/common/__tests__/authService.spec.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import authService from '../authService.js';
33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44
import axios, { AxiosError } from 'axios';
5+
import { mock } from 'node:test';
56

67
vi.mock('axios', async () => {
78
const actual: any = await vi.importActual('axios');
@@ -43,15 +44,23 @@ describe('Auth Service', () => {
4344
describe('refreshAuthToken', () => {
4445
// check for valid scenario , that api returns token
4546
it('should return token if api respond is OK.', async () => {
47+
const mockData = {
48+
jwtFrontend: '0.eyJpYXQiOjE1MTYyMzkwMjIsImVhdCI6MTUxODAzOTAyMn0.0',
49+
correlationID: 'testCorrelationID',
50+
};
4651
vi.spyOn(axios, 'post').mockResolvedValueOnce({
47-
data: {
48-
jwtFrontend: 'testToken',
49-
correlationID: 'testCorrelationID',
50-
},
52+
data: mockData,
5153
});
52-
const data = await authService.refreshAuthToken('testToken', 'testCorrelationID');
53-
expect(data.jwtFrontend).toBe('testToken');
54-
expect(data.correlationID).toBe('testCorrelationID');
54+
const data = await authService.refreshAuthToken(
55+
'testToken',
56+
'testCorrelationID',
57+
);
58+
const expectedData = {
59+
...mockData,
60+
eat: 1518039022,
61+
iat: 1516239022,
62+
};
63+
expect(data).toEqual(expectedData);
5564
});
5665
it('should return error if api respond contains error.', async () => {
5766
vi.spyOn(axios, 'post').mockResolvedValueOnce({
@@ -60,7 +69,10 @@ describe('Auth Service', () => {
6069
error_description: 'refresh token expired.',
6170
},
6271
});
63-
const data = await authService.refreshAuthToken('testToken', 'testCorrelationID');
72+
const data = await authService.refreshAuthToken(
73+
'testToken',
74+
'testCorrelationID',
75+
);
6476
expect(data.error).toBe('refresh token expired.');
6577
});
6678
it('should reject if api respond is unauthorized.', async () => {

frontend/src/common/apiService.ts

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
IAnnouncementSearchResult,
77
} from '../types/announcements';
88
import { ApiRoutes } from '../utils/constant';
9-
import AuthService from './authService';
9+
import { authStore } from '../store/modules/auth';
1010
import { IConfigValue, IReport } from './types';
1111

1212
export enum REPORT_FORMATS {
@@ -46,40 +46,44 @@ function processQueue(error, token = null) {
4646

4747
// Create new non-global axios instance and intercept strategy
4848
const apiAxios = axios.create();
49-
const responseErrorInterceptor = (error) => {
49+
50+
const responseErrorInterceptor = async (error) => {
5051
const originalRequest = error.config;
51-
if (error.response.status !== 401) {
52-
return Promise.reject(new Error('AxiosError', { cause: error }));
52+
53+
// If there's an error, but not authentication related, reject immediately
54+
// If this request has already been retried, don't retry again
55+
if (error.response?.status !== 401 || originalRequest.authAlreadyRetried) {
56+
throw new Error('AxiosError', { cause: error });
57+
}
58+
59+
originalRequest.authAlreadyRetried = true; // Mark as retried to prevent authentication loops
60+
61+
try {
62+
const aStore = authStore();
63+
await aStore.getJwtToken();
64+
65+
if (!aStore.isAuthenticated || !aStore.jwtToken) {
66+
throw new Error('Authentication failed');
67+
}
68+
69+
// The auth store already updates ApiService headers, but we need to update
70+
// the original request headers for the retry
71+
originalRequest.headers['Authorization'] = `Bearer ${aStore.jwtToken}`;
72+
originalRequest.headers['x-correlation-id'] =
73+
localStorage.getItem('correlationID');
74+
75+
processQueue(null, aStore.jwtToken);
76+
77+
// Retry the original request
78+
return apiAxios.request(originalRequest);
79+
} catch (e) {
80+
processQueue(e, null);
81+
82+
globalThis.location.href = '/token-expired';
83+
throw new Error('token expired', { cause: e });
5384
}
54-
axios.interceptors.response.eject(intercept);
55-
return new Promise((resolve, reject) => {
56-
AuthService.refreshAuthToken(
57-
localStorage.getItem('jwtToken'),
58-
localStorage.getItem('correlationID'),
59-
)
60-
.then((response) => {
61-
if (response.jwtFrontend) {
62-
localStorage.setItem('jwtToken', response.jwtFrontend);
63-
localStorage.setItem('correlationID', response.correlationID);
64-
apiAxios.defaults.headers.common['Authorization'] =
65-
`Bearer ${response.jwtFrontend}`;
66-
originalRequest.headers['Authorization'] =
67-
`Bearer ${response.jwtFrontend}`;
68-
apiAxios.defaults.headers.common['x-correlation-id'] =
69-
response.correlationID;
70-
originalRequest.headers['x-correlation-id'] = response.correlationID;
71-
}
72-
processQueue(null, response.jwtFrontend);
73-
resolve(axios.request(originalRequest));
74-
})
75-
.catch((e) => {
76-
processQueue(e, null);
77-
localStorage.removeItem('jwtToken');
78-
globalThis.location.href = '/token-expired';
79-
reject(new Error('token expired', { cause: e }));
80-
});
81-
});
8285
};
86+
8387
const intercept = apiAxios.interceptors.response.use(
8488
(config) => config,
8589
responseErrorInterceptor,

frontend/src/common/authService.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import axios from 'axios';
22
import { AuthRoutes } from '../utils/constant.js';
33

4+
function parseJwt(token) {
5+
if (!token) return {};
6+
const base64Url = token.split('.')[1];
7+
if (!base64Url) return {};
8+
const base64 = base64Url.replaceAll('-', '+').replaceAll('_', '/');
9+
const jsonPayload = decodeURIComponent(
10+
globalThis
11+
.atob(base64)
12+
.split('')
13+
.map(function (c) {
14+
return '%' + ('00' + c.codePointAt(0).toString(16)).slice(-2);
15+
})
16+
.join(''),
17+
);
18+
19+
return JSON.parse(jsonPayload);
20+
}
21+
422
export default {
523
//Retrieves an auth token from the API endpoint
624
async getAuthToken() {
@@ -32,9 +50,9 @@ export default {
3250
return { error: response.data.error_description };
3351
}
3452

35-
return response.data;
53+
return { ...response.data, ...parseJwt(response.data.jwtFrontend) };
3654
} catch (e) {
37-
console.log(`Failed to refresh JWT token - ${e}`); // eslint-disable-line no-console
55+
console.log(`Failed to refresh JWT token - ${e}`);
3856
throw e;
3957
}
4058
},

frontend/src/common/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ export interface IReport {
1515
naics_code: string;
1616
report_status: string;
1717
employee_count_range_id: string;
18+
user_comment: string | null;
19+
data_constraints: string | null;
1820
}

frontend/src/components/InputForm.vue

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ import { NotificationService } from '../common/notificationService';
729729
import { CsvService, IParseSuccessResponse } from '../common/csvService';
730730
import { LocalDate, TemporalAdjusters, DateTimeFormatter } from '@js-joda/core';
731731
import { Locale } from '@js-joda/locale_en';
732-
import { IConfigValue } from '../common/types';
732+
import { IConfigValue, IReport } from '../common/types';
733733
import axios from 'axios';
734734
import { VFileInput } from 'vuetify/components';
735735
import _ from 'lodash';
@@ -839,7 +839,7 @@ export default {
839839
computed: {
840840
...mapState(useConfigStore, ['config']),
841841
...mapState(useCodeStore, ['employeeCountRanges', 'naicsCodes']),
842-
...mapState(authStore, ['userInfo']),
842+
...mapState(authStore, ['userInfo', 'keepAlive', 'stopKeepAlive']),
843843
...mapState(useReportStepperStore, [
844844
'reportId',
845845
'reportInfo',
@@ -1004,16 +1004,32 @@ export default {
10041004
},
10051005
},
10061006
async mounted() {
1007+
await this.keepAlive();
10071008
this.setStage('UPLOAD');
10081009
this.loadConfig()?.catch(() => {
10091010
NotificationService.pushNotificationError(
10101011
'Failed to load application settings. Please reload the page.',
10111012
);
10121013
});
1014+
const saved = sessionStorage.getItem('backupFormDraft');
1015+
if (saved) {
1016+
sessionStorage.removeItem('backupFormDraft');
1017+
try {
1018+
const draft = JSON.parse(saved);
1019+
this.setReportInfo(draft);
1020+
this.initFormInEditMode();
1021+
} catch (e) {
1022+
console.error('Failed to parse form draft from sessionStorage', e);
1023+
return;
1024+
}
1025+
}
10131026
if (this.isEditMode) {
10141027
this.initFormInEditMode();
10151028
}
10161029
},
1030+
beforeUnmount() {
1031+
this.stopKeepAlive();
1032+
},
10171033
methods: {
10181034
...mapActions(useReportStepperStore, [
10191035
'setStage',
@@ -1180,7 +1196,6 @@ export default {
11801196
async submit() {
11811197
this.isSubmit = true;
11821198
if (!this.formReady) {
1183-
console.log(this.companyName, this.companyAddress, this.naicsCode);
11841199
throw new Error('Form missing required fields');
11851200
}
11861201
this.isProcessing = true;
@@ -1225,6 +1240,22 @@ export default {
12251240
throw new Error(DEFAULT_SUBMISSION_ERROR_MESSAGE);
12261241
}
12271242
} catch (error: any) {
1243+
if (submission) {
1244+
const backupDraft: Partial<IReport> = {
1245+
report_id: submission.id || '',
1246+
report_start_date: submission.startDate,
1247+
report_end_date: submission.endDate,
1248+
reporting_year: submission.reportingYear,
1249+
naics_code: submission.naicsCode,
1250+
employee_count_range_id: submission.employeeCountRangeId,
1251+
user_comment: submission.comments,
1252+
data_constraints: submission.dataConstraints,
1253+
};
1254+
sessionStorage.setItem(
1255+
'backupFormDraft',
1256+
JSON.stringify(backupDraft),
1257+
);
1258+
}
12281259
// Handle different kinds of error objects by converting them
12291260
// into a SubmissionError
12301261
this.onSubmitComplete(this.toISubmissionError(error));

0 commit comments

Comments
 (0)