Skip to content

Commit

Permalink
Merge pull request #1237 from US-Trustee-Program/CAMS-513-session-deb…
Browse files Browse the repository at this point in the history
…ugging

CAMS-513 Added session check hook to App.tsx
  • Loading branch information
jamesobrooks authored Mar 3, 2025
2 parents 9d27ee5 + 23bda63 commit a7a095d
Show file tree
Hide file tree
Showing 12 changed files with 154 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MongoCollectionAdapter } from './utils/mongo-adapter';
import { closeDeferred } from '../../../deferrable/defer-close';
import QueryBuilder from '../../../query/query-builder';
import { NotFoundError } from '../../../common-errors/not-found-error';
import { nowInSeconds } from '../../../../../common/src/date-helper';

describe('User session cache Cosmos repository tests', () => {
let context: ApplicationContext;
Expand Down Expand Up @@ -85,7 +86,7 @@ describe('User session cache Cosmos repository tests', () => {
}),
true,
);
const maxTtl = Math.floor(camsJwtClaims.exp - Date.now() / 1000);
const maxTtl = Math.floor(camsJwtClaims.exp - nowInSeconds());
expect(argument.ttl).toBeLessThan(maxTtl + 1);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getCamsError } from '../../../common-errors/error-utilities';
import QueryBuilder from '../../../query/query-builder';
import { UserSessionCacheRepository } from '../../../use-cases/gateways.types';
import { BaseMongoRepository } from './utils/base-mongo-repository';
import { nowInSeconds } from '../../../../../common/src/date-helper';

const MODULE_NAME: string = 'USER_SESSION_CACHE_MONGO_REPOSITORY';
const COLLECTION_NAME: string = 'user-session-cache';
Expand Down Expand Up @@ -75,7 +76,7 @@ export class UserSessionCacheMongoRepository
let ttl;
try {
const tokenParts = session.accessToken.split('.');
ttl = Math.floor(claims.exp - Date.now() / 1000);
ttl = Math.floor(claims.exp - nowInSeconds());
signature = tokenParts[2];
} catch {
throw new UnauthorizedError(MODULE_NAME, { message: 'Invalid token received.' });
Expand Down
3 changes: 2 additions & 1 deletion backend/lib/adapters/gateways/okta/okta-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import OktaGateway from './okta-gateway';
import { CamsJwtHeader } from '../../../../../common/src/cams/jwt';
import * as AuthorizationConfiguration from '../../../configs/authorization-configuration';
import { AuthorizationConfig } from '../../types/authorization';
import { nowInSeconds } from '../../../../../common/src/date-helper';

describe('Okta gateway tests', () => {
const gateway = OktaGateway;
Expand Down Expand Up @@ -63,7 +64,7 @@ describe('Okta gateway tests', () => {
sub: '[email protected]',
aud: 'api://default',
iat: 0,
exp: Math.floor(Date.now() / 1000) + 600,
exp: nowInSeconds() + 600,
AD_Groups: ['groupD'],
ad_groups: ['groupA', 'groupB'],
groups: ['groupB', 'groupC'],
Expand Down
9 changes: 7 additions & 2 deletions backend/lib/testing/mock-gateways/mock-oauth2-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import { CamsRole } from '../../../../common/src/cams/roles';
import { CamsJwt, CamsJwtClaims, CamsJwtHeader } from '../../../../common/src/cams/jwt';
import { OpenIdConnectGateway } from '../../adapters/types/authorization';
import { MOCKED_USTP_OFFICES_ARRAY } from '../../../../common/src/cams/offices';
import { nowInSeconds } from '../../../../common/src/date-helper';

const MODULE_NAME = 'MOCK-OAUTH2-GATEWAY';
const mockUsers: MockUser[] = MockUsers;
const key = 'mock-secret'; //pragma: allowlist secret

const EXPIRE_OVERRIDE = parseInt(process.env.MOCK_SESSION_EXPIRE_LENGTH);

export async function mockAuthentication(context: ApplicationContext): Promise<string> {
if (context.config.authConfig.provider !== 'mock') {
throw new ForbiddenError(MODULE_NAME, { message: 'Not in mock mode...' });
Expand All @@ -20,13 +23,15 @@ export async function mockAuthentication(context: ApplicationContext): Promise<s
const validMockRole = mockUsers.find((role) => role.sub === requestedSubject.sub);

const ONE_DAY = 60 * 60 * 24;
const SECONDS_SINCE_EPOCH = Math.floor(Date.now() / 1000);
const NOW = nowInSeconds();

const expiration = isNaN(EXPIRE_OVERRIDE) ? NOW + ONE_DAY : NOW + EXPIRE_OVERRIDE;

const claims: CamsJwtClaims = {
aud: 'api://default',
sub: validMockRole.sub,
iss: context.request.url,
exp: SECONDS_SINCE_EPOCH + ONE_DAY,
exp: expiration,
groups: [],
};

Expand Down
6 changes: 3 additions & 3 deletions common/src/cams/test-utilities/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
import { CamsSession } from '../session';
import { CamsJwtClaims } from '../jwt';
import { Pagination } from '../../api/pagination';
import { getIsoDate, getTodaysIsoDate, sortDates } from '../../date-helper';
import { getIsoDate, getTodaysIsoDate, nowInSeconds, sortDates } from '../../date-helper';
import { CamsRole } from '../roles';
import { MOCKED_USTP_OFFICES_ARRAY } from '../offices';
import { REGION_02_GROUP_NY } from './mock-user';
Expand Down Expand Up @@ -654,15 +654,15 @@ function getManhattanTrialAttorneySession(): CamsSession {
}

function getJwt(claims: Partial<CamsJwtClaims> = {}): string {
const SECONDS_SINCE_EPOCH = Math.floor(Date.now() / 1000);
const NOW = nowInSeconds();
const ONE_HOUR = 3600;
const salt = Math.floor(Math.random() * 10);

const payload: CamsJwtClaims = {
iss: 'http://fake.issuer.com/oauth2/default',
sub: '[email protected]',
aud: 'fakeApi',
exp: SECONDS_SINCE_EPOCH + ONE_HOUR + salt,
exp: NOW + ONE_HOUR + salt,
groups: [],
...claims,
};
Expand Down
5 changes: 5 additions & 0 deletions common/src/date-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ export function getTodaysIsoDate() {
return getIsoDate(new Date());
}

export function nowInSeconds() {
return Math.floor(Date.now() / 1000);
}

export const DateHelper = {
getIsoDate,
getTodaysIsoDate,
isValidDateString,
nowInSeconds,
sortDates,
sortDatesReverse,
};
Expand Down
7 changes: 7 additions & 0 deletions user-interface/src/login/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { CamsSession } from '@common/cams/session';
import { SUPERUSER } from '@common/cams/test-utilities/mock-user';
import { initializeBroadcastLogout } from '@/login/broadcast-logout';
import LocalCache from '@/lib/utils/local-cache';
import { nowInSeconds } from '@common/date-helper';
import { Logout } from './Logout';

export type LoginProps = PropsWithChildren & {
provider?: LoginProvider;
Expand All @@ -48,6 +50,11 @@ export function Login(props: LoginProps): React.ReactNode {
initializeBroadcastLogout();

const session: CamsSession | null = LocalStorage.getSession();

if (session && session.expires < nowInSeconds()) {
return <Logout></Logout>;
}

if (session) {
if (provider == 'okta') {
issuer = getAuthIssuerFromEnv();
Expand Down
2 changes: 2 additions & 0 deletions user-interface/src/login/Session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Interstitial } from './Interstitial';
import { CamsSession } from '@common/cams/session';
import { CamsUser } from '@common/cams/users';
import useCamsNavigator from '@/lib/hooks/UseCamsNavigator';
import { initializeSessionEndLogout } from './session-end-logout';

type SessionState = {
isLoaded: boolean;
Expand All @@ -23,6 +24,7 @@ export function useStateAndActions() {
});

function postLoginTasks(session: CamsSession) {
initializeSessionEndLogout(session);
session.user.offices?.forEach((office) => {
Api2.getOfficeAttorneys(office.officeCode);
});
Expand Down
17 changes: 12 additions & 5 deletions user-interface/src/login/providers/okta/okta-library.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import OktaAuth, { UserClaims } from '@okta/okta-auth-js';
import LocalStorage from '@/lib/utils/local-storage';
import { addApiBeforeHook } from '@/lib/models/api';
import { nowInSeconds } from '@common/date-helper';
import Api2 from '@/lib/models/api2';
import { initializeSessionEndLogout } from '@/login/session-end-logout';

const SAFE_LIMIT = 2700;

Expand All @@ -9,12 +12,11 @@ export function registerRefreshOktaToken(oktaAuth: OktaAuth) {
}

export function getCamsUser(oktaUser: UserClaims | null) {
// TODO: We need to decide which claim we map to the CamsUser.id
return { id: oktaUser?.sub ?? 'UNKNOWN', name: oktaUser?.name ?? oktaUser?.email ?? 'UNKNOWN' };
}

export async function refreshOktaToken(oktaAuth: OktaAuth) {
const now = Math.floor(Date.now() / 1000);
const now = nowInSeconds();
const session = LocalStorage.getSession();
if (!session) return;

Expand Down Expand Up @@ -45,16 +47,21 @@ async function refreshTheToken(oktaAuth: OktaAuth) {
const oktaUser = await oktaAuth.getUser();
if (accessToken) {
const jwt = oktaAuth.token.decode(accessToken);
// TODO: THIS REFRESH IS NOT "AUGMENTED". WE NEED TO CALL THE /me ENDPOINT.
// Map Okta user information to CAMS user
// TODO: This is the first of two calls to getUser, but this response is not the one we use. The api returns user details with the /me endpoint. Just skip this call??
// Set the skeleton of a CamsSession object in local storage for the API.
LocalStorage.setSession({
provider: 'okta',
accessToken,
user: getCamsUser(oktaUser),
expires: jwt.payload.exp ?? 0,
issuer: jwt.payload.iss ?? '',
});

// Then call the /me endpoint to cache the Okta session on the API side and
// and get the full CamsSession with CAMS-specific user detail not available
// from Okta.
const me = await Api2.getMe();
LocalStorage.setSession(me.data);
initializeSessionEndLogout(me.data);
}
} catch {
// failed to renew access token.
Expand Down
94 changes: 94 additions & 0 deletions user-interface/src/login/session-end-logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import LocalStorage from '@/lib/utils/local-storage';
import { LOGOUT_PATH } from '@/login/login-library';
import { checkForSessionEnd, initializeSessionEndLogout } from '@/login/session-end-logout';
import { CamsSession } from '@common/cams/session';
import MockData from '@common/cams/test-utilities/mock-data';
import { nowInSeconds } from '@common/date-helper';

describe('Session End Logout tests', () => {
const host = 'camshost';
const protocol = 'http:';
const assign = vi.fn();

const mockLocation: Location = {
assign,
host,
protocol,
hash: '',
hostname: '',
href: '',
origin: '',
pathname: '',
port: '',
search: '',
reload: vi.fn(),
replace: vi.fn(),
ancestorOrigins: {
length: 0,
item: vi.fn(),
contains: vi.fn(),
[Symbol.iterator]: vi.fn(),
},
} as const;

const logoutUri = protocol + '//' + host + LOGOUT_PATH;

beforeEach(() => {
window.location = { ...mockLocation };
});

afterEach(() => {
vi.restoreAllMocks();
});

test('should redirect if session doesnt exist', () => {
vi.spyOn(LocalStorage, 'getSession').mockReturnValue(null);
checkForSessionEnd();
expect(assign).toHaveBeenCalledWith(logoutUri);
});

test('should redirect if session is expired', () => {
const oneSecondAgo = nowInSeconds() - 1000;
const session: CamsSession = {
user: MockData.getCamsUser(),
accessToken: MockData.getJwt(),
provider: 'mock',
issuer: '',
expires: oneSecondAgo,
};
vi.spyOn(LocalStorage, 'getSession').mockReturnValue(session);
checkForSessionEnd();
expect(assign).toHaveBeenCalledWith(logoutUri);
});

test('should not redirect if session is not expired', () => {
const tenSecondsFromNow = nowInSeconds() + 10000;
const session: CamsSession = {
user: MockData.getCamsUser(),
accessToken: MockData.getJwt(),
provider: 'mock',
issuer: '',
expires: tenSecondsFromNow,
};
vi.spyOn(LocalStorage, 'getSession').mockReturnValue(session);
checkForSessionEnd();
expect(assign).not.toHaveBeenCalledWith(logoutUri);
});

test('should call setInterval correctly', () => {
const tenSecondsFromNow = nowInSeconds() + 10000;
const session: CamsSession = {
user: MockData.getCamsUser(),
accessToken: MockData.getJwt(),
provider: 'mock',
issuer: '',
expires: tenSecondsFromNow,
};
const setIntervalSpy = vi.spyOn(global, 'setInterval');

initializeSessionEndLogout(session);
const milliseconds = 10000000;
expect(setIntervalSpy.mock.calls[0][1]).toBeGreaterThan(milliseconds - 5);
expect(setIntervalSpy.mock.calls[0][1]).toBeLessThan(milliseconds + 5);
});
});
18 changes: 18 additions & 0 deletions user-interface/src/login/session-end-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import LocalStorage from '@/lib/utils/local-storage';
import { LOGOUT_PATH } from './login-library';
import { redirectTo } from '@/lib/hooks/UseCamsNavigator';
import { CamsSession } from '@common/cams/session';
import { nowInSeconds } from '@common/date-helper';

export function checkForSessionEnd() {
const session = LocalStorage.getSession();
if (!session || session.expires <= nowInSeconds()) {
const { host, protocol } = window.location;
const logoutUri = protocol + '//' + host + LOGOUT_PATH;
redirectTo(logoutUri);
}
}

export function initializeSessionEndLogout(session: CamsSession) {
setInterval(checkForSessionEnd, Math.floor(session.expires - nowInSeconds()) * 1000);
}
4 changes: 0 additions & 4 deletions user-interface/src/my-cases/MyCasesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,15 @@ import './MyCasesScreen.scss';
import ScreenInfoButton from '@/lib/components/cams/ScreenInfoButton';
import DocumentTitle from '@/lib/components/cams/DocumentTitle/DocumentTitle';
import { MainContent } from '@/lib/components/cams/MainContent/MainContent';
import { useNavigate } from 'react-router-dom';
import { LOGOUT_SESSION_END_PATH } from '@/login/login-library';

export const MyCasesScreen = () => {
const screenTitle = 'My Cases';

const infoModalRef = useRef(null);
const infoModalId = 'info-modal';
const session = LocalStorage.getSession();
const navigate = useNavigate();

if (!session || !session.user.offices) {
navigate(LOGOUT_SESSION_END_PATH);
return <></>;
}

Expand Down

0 comments on commit a7a095d

Please sign in to comment.