diff --git a/jest.config.js b/jest.config.ts similarity index 58% rename from jest.config.js rename to jest.config.ts index 860e968..dccb280 100644 --- a/jest.config.js +++ b/jest.config.ts @@ -1,9 +1,14 @@ -const { pathsToModuleNameMapper } = require('ts-jest') -const { compilerOptions } = require('./tsconfig.json') -const { jestGlobalConfig } = require('@reapit/ts-scripts') +import { pathsToModuleNameMapper, JestConfigWithTsJest } from 'ts-jest' +import { compilerOptions } from './tsconfig.json' +import { jestGlobalConfig } from '@reapit/ts-scripts' -module.exports = { +const config: JestConfigWithTsJest = { ...jestGlobalConfig, + setupFiles: [], + coverageReporters: ['json-summary', 'text', 'lcov'], + projects: undefined, + verbose: undefined, + reporters: ['default'], modulePathIgnorePatterns: ['[/\\\\](node_modules|public|dist)[/\\\\]'], moduleNameMapper: { ...pathsToModuleNameMapper(compilerOptions.paths, { @@ -25,3 +30,5 @@ module.exports = { }, }, } + +export default config diff --git a/package.json b/package.json index ca8b21c..d089f11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reapit/connect-session", - "version": "6.1.3", + "version": "6.2.0", "description": "OAuth Flow for Reapit Connect", "keywords": [], "homepage": "https://github.com/reapit/foundations#readme", @@ -37,16 +37,9 @@ "commit": "yarn test --coverage --no-cache --silent --forceExit --detectOpenHandles --runInBand --watch=false && jest-coverage-badges --input src/tests/coverage/coverage-summary.json --output src/tests/badges && yarn lint --fix && yarn check" }, "dependencies": { - "@aws-crypto/sha256-browser": "^5.2.0", - "axios": "^1.7.7", - "base-64": "^1.0.0", - "idtoken-verifier": "^2.2.4", - "isomorphic-fetch": "^3.0.0", - "jwt-decode": "^3.1.2", + "@auth0/auth0-spa-js": "^2.1.3", "react": "^18.2.0", - "react-dom": "^18.2.0", - "text-encoding": "^0.7.0", - "uuid": "^9.0.1" + "react-dom": "^18.2.0" }, "devDependencies": { "@linaria/react": "^6.2.1", @@ -57,10 +50,8 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@testing-library/react-hooks": "^8.0.1", - "@types/base-64": "^1.0.2", "@types/jest": "^29.5.13", "@types/react": "^18.3.11", - "@types/text-encoding": "^0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "babel-jest": "^29.7.0", @@ -70,6 +61,7 @@ "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", "jest-coverage-badges": "^1.1.2", + "jest-environment-jsdom": "^29.7.0", "rimraf": "^5.0.5", "rollup": "^4.13.0", "snyk": "^1.1285.0", diff --git a/src/__mocks__/session.ts b/src/__mocks__/session.ts deleted file mode 100644 index ae782fa..0000000 --- a/src/__mocks__/session.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ReapitConnectSession } from '../index' -import { ReapitConnectBrowserSessionInitializers, CoginitoSession } from '../types' -import base64 from 'base-64' - -export const createMockToken = (token: { [s: string]: any } | string): string => - `${base64.encode('{}')}.${base64.encode(typeof token === 'string' ? token : JSON.stringify(token))}.${base64.encode( - '{}', - )}` - -export const mockLoginIdentity = { - email: 'name@mail.com', - name: 'name', - developerId: 'SOME_DEVELOPER_ID', - clientId: 'SOME_CLIENT_ID', - adminId: 'SOME_ADMIN_ID', - userCode: 'SOME_USER_ID', - orgName: 'SOME_ORG_NAME', - orgId: 'SOME_ORG_ID', - groups: ['AgencyCloudDeveloperEdition', 'OrganisationAdmin', 'ReapitUser', 'ReapitDeveloper', 'ReapitDeveloperAdmin'], - offGroupIds: 'MKV', - offGrouping: true, - offGroupName: 'Cool Office Group', - officeId: 'MVK', - orgProduct: 'agencyCloud', - agencyCloudId: 'SOME_AC_ID', -} - -export const mockBrowserSession: ReapitConnectSession = { - accessToken: createMockToken({ - exp: Math.round(new Date().getTime() / 1000) + 360, // time now + 6mins - we refresh session if expiry within 5mins - }), - refreshToken: 'SOME_REFRESH_TOKEN', - idToken: createMockToken({ - name: mockLoginIdentity.name, - email: mockLoginIdentity.email, - 'custom:reapit:developerId': mockLoginIdentity.developerId, - 'custom:reapit:clientCode': mockLoginIdentity.clientId, - 'custom:reapit:marketAdmin': mockLoginIdentity.adminId, - 'custom:reapit:userCode': mockLoginIdentity.userCode, - 'cognito:groups': mockLoginIdentity.groups, - }), - loginIdentity: mockLoginIdentity, -} - -export const mockBrowserInitializers: ReapitConnectBrowserSessionInitializers = { - connectClientId: 'SOME_CLIENT_ID', - connectOAuthUrl: 'SOME_URL', - connectUserPoolId: 'SOME_USER_POOL_ID', - connectLoginRedirectPath: '/some-route', - connectLogoutRedirectPath: '/some-other-route', -} - -export const mockTokenResponse: CoginitoSession = { - access_token: mockBrowserSession.accessToken, - refresh_token: mockBrowserSession.refreshToken, - id_token: mockBrowserSession.idToken, -} diff --git a/src/browser.test.ts b/src/browser.test.ts new file mode 100644 index 0000000..89914fc --- /dev/null +++ b/src/browser.test.ts @@ -0,0 +1,361 @@ +const auth0ClientMock = jest.fn() +jest.doMock('@auth0/auth0-spa-js', () => ({ + Auth0Client: auth0ClientMock, +})) + +const loginWithRedirect = jest.fn() +const logout = jest.fn() +const isAuthenticated = jest.fn() +const getTokenSilently = jest.fn() +const getIdTokenClaims = jest.fn() +const handleRedirectCallback = jest.fn() + +beforeEach(() => { + jest.resetAllMocks() + + auth0ClientMock.mockReturnValue({ + loginWithRedirect, + logout, + isAuthenticated, + getTokenSilently, + getIdTokenClaims, + handleRedirectCallback, + }) + + Object.defineProperty(window, 'location', { + writable: true, + value: { + origin: 'http://example.org', + href: 'http://example.org/app', + pathname: '/app', + search: 'code=something&else=asdfg', + }, + }) + + Object.defineProperty(window, ReapitConnectBrowserSession.GLOBAL_KEY, { + value: undefined, + writable: true, + }) + + jest.useFakeTimers() + jest.spyOn(window, 'setTimeout') +}) + +import { ReapitConnectBrowserSession } from './browser' + +const createSession = () => { + const connectClientId = 'client-id' + const connectOAuthUrl = 'oauth-url' + const connectLoginRedirectPath = '/something' + return new ReapitConnectBrowserSession({ + connectClientId, + connectOAuthUrl, + connectLoginRedirectPath, + }) +} + +describe('connect-session browser', () => { + it('should instantiate Auth0Client with the correct config', () => { + createSession() + + expect(auth0ClientMock).toHaveBeenCalledWith({ + clientId: 'client-id', + domain: 'oauth-url', + authorizationParams: { + redirect_uri: 'http://example.org/something', + }, + useRefreshTokens: true, + cache: undefined, + cacheLocation: 'localstorage', + }) + }) + + it('should bind public methods', () => { + const session = createSession() + const ccs = session.connectClearSession + // this will throw if unbound since this will be undefined so it can't set this.forceRefresh to true + expect(() => ccs()).not.toThrow() + }) + + describe('idle timeout', () => { + it('should reset the timer on interaction', () => { + const session = createSession() + + document?.onmousemove?.({} as any) + + expect(window.setTimeout).toHaveBeenCalledWith(session.connectLogoutRedirect, 10800000) + }) + + it('should log the user out if timer ends', () => { + createSession() + document?.onmousemove?.({} as any) + jest.runAllTimers() + expect(window.location.href).toBe( + 'oauth-url/oidc/logout?post_logout_redirect_uri=http%3A%2F%2Fexample.org%2Flogin&client_id=client-id', + ) + }) + }) + + describe('connectIsDesktop', () => { + it('should be true if window.__REAPIT_MARKETPLACE_GLOBALS__ exists', () => { + window[ReapitConnectBrowserSession.GLOBAL_KEY] = 'something' + const session = createSession() + console.log('window[ReapitConnectBrowserSession.GLOBAL_KEY]', window[ReapitConnectBrowserSession.GLOBAL_KEY]) + expect(session.connectIsDesktop).toBe(true) + }) + it('should be false if window.__REAPIT_MARKETPLACE_GLOBALS__ doesnt exist', () => { + const session = createSession() + expect(session.connectIsDesktop).toBe(false) + }) + }) + + describe('connectAuthorizeRedirect', () => { + it('should call loginWithRedirect with appState internalRedirectPath being the current url minus the code param', async () => { + const session = createSession() + await session.connectAuthorizeRedirect() + expect(loginWithRedirect.mock.calls[0][0].appState).toStrictEqual({ + internalRedirectPath: '/app?else=asdfg', + }) + }) + it('should call loginWithRedirect with authorizationParams redirect_uri being the first argument if present', async () => { + const session = createSession() + await session.connectAuthorizeRedirect('redirect-uri') + expect(loginWithRedirect.mock.calls[0][0].authorizationParams).toStrictEqual({ + redirect_uri: 'redirect-uri', + }) + await session.connectAuthorizeRedirect() + expect(loginWithRedirect.mock.calls[1][0].authorizationParams).toStrictEqual({ + redirect_uri: undefined, + }) + }) + }) + + describe('connectLoginRedirect', () => { + it('should call loginWithRedirect with appState internalRedirectPath being the current url minus the code param', () => { + const session = createSession() + session.connectLoginRedirect() + expect(loginWithRedirect.mock.calls[0][0].appState).toStrictEqual({ + internalRedirectPath: '/app?else=asdfg', + }) + }) + it('should call loginWithRedirect with authorizationParams redirect_uri being the first argument if present', () => { + const session = createSession() + session.connectLoginRedirect('redirect-uri') + expect(loginWithRedirect.mock.calls[0][0].authorizationParams).toStrictEqual({ + redirect_uri: 'redirect-uri', + }) + session.connectLoginRedirect() + expect(loginWithRedirect.mock.calls[1][0].authorizationParams).toStrictEqual({ + redirect_uri: undefined, + }) + }) + }) + + describe('connectLogoutRedirect', () => { + it('should call logout and redirect the user to /oidc/logout with post_logout_redirect_uri and client_id', () => { + const session = createSession() + session.connectLogoutRedirect() + expect(logout).toHaveBeenCalledWith({ + openUrl: false, + }) + expect(window.location.href).toBe( + 'oauth-url/oidc/logout?post_logout_redirect_uri=http%3A%2F%2Fexample.org%2Flogin&client_id=client-id', + ) + }) + + it('should override post_logout_redirect_uri with the first arg', () => { + const session = createSession() + session.connectLogoutRedirect('somewhere') + expect(window.location.href).toBe('oauth-url/oidc/logout?post_logout_redirect_uri=somewhere&client_id=client-id') + }) + }) + + describe('connectHasSession', () => { + it('should false if the user has not previously authenticated', () => { + expect(createSession().connectHasSession).toBe(false) + }) + }) + + describe('connectSession', () => { + beforeEach(() => { + handleRedirectCallback.mockResolvedValue({ + appState: { + internalRedirectPath: 'internal-redirect-path', + }, + }) + }) + + describe('with code in the url', () => { + it('should call handleRedirectCallback', async () => { + const session = createSession() + await session.connectSession() + expect(handleRedirectCallback).toHaveBeenCalled() + }) + + it('should only call handleRedirectCallback once per code in the url', async () => { + const session = createSession() + await session.connectSession() + await session.connectSession() + expect(handleRedirectCallback).toHaveBeenCalledTimes(1) + }) + + it('should set the internalRedirectPath from the appState to connectInternalRedirect', async () => { + const session = createSession() + await session.connectSession() + expect(session.connectInternalRedirect).toBe('internal-redirect-path') + }) + }) + + describe('without code in the url', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: { + origin: 'http://example.org', + href: 'http://example.org/app', + pathname: '/app', + search: 'else=asdfg', + }, + }) + }) + + describe('authorized', () => { + beforeEach(() => { + isAuthenticated.mockResolvedValue(true) + getTokenSilently.mockResolvedValue('access-token') + getIdTokenClaims.mockResolvedValue({ + __raw: 'raw-id-token', + name: 'name-from-token', + email: 'email-address', + 'custom:reapit:agencyCloudId': 'agency-cloud-id', + 'custom:reapit:developerId': 'developer-id', + 'custom:reapit:clientCode': 'client-code', + 'custom:reapit:marketAdmin': 'market-admin', + 'custom:reapit:userCode': 'user-code', + 'cognito:groups': 'groups', + 'custom:reapit:orgName': 'org-name', + 'custom:reapit:orgId': 'org-id', + 'custom:reapit:offGroupIds': 'off-group-ids', + 'custom:reapit:offGrouping': 'true', + 'custom:reapit:offGroupName': 'off-group-name', + 'custom:reapit:officeId': 'office-id', + 'custom:reapit:orgProduct': 'org-product', + }) + }) + + it('should make connectHasSession return true', async () => { + const session = createSession() + await session.connectSession() + expect(session.connectHasSession).toBe(true) + }) + + it('should return the token from getTokenSilently', async () => { + const session = createSession() + const cs = await session.connectSession() + expect(cs).toBeDefined() + if (cs) { + expect(cs.accessToken).toBe('access-token') + } + }) + + describe('connectClearSession', () => { + it('should force the getTokenSilently cache off if connectClearSession was previously called', async () => { + const session = createSession() + await session.connectSession() + expect(getTokenSilently).toHaveBeenLastCalledWith({ + cacheMode: 'on', + }) + session.connectClearSession() + + await session.connectSession() + expect(getTokenSilently).toHaveBeenLastCalledWith({ + cacheMode: 'off', + }) + }) + + it('should force the getTokenSilently cache on if connectClearSession was previously called and the token was then refetched', async () => { + const session = createSession() + await session.connectSession() + expect(getTokenSilently).toHaveBeenLastCalledWith({ + cacheMode: 'on', + }) + session.connectClearSession() + + await session.connectSession() + expect(getTokenSilently).toHaveBeenLastCalledWith({ + cacheMode: 'off', + }) + await session.connectSession() + expect(getTokenSilently).toHaveBeenLastCalledWith({ + cacheMode: 'on', + }) + + await session.connectSession() + expect(getTokenSilently).toHaveBeenLastCalledWith({ + cacheMode: 'on', + }) + }) + }) + + it('should return the raw id token from getIdTokenClaims', async () => { + const session = createSession() + const cs = await session.connectSession() + expect(cs).toBeDefined() + if (cs) { + expect(cs.idToken).toBe('raw-id-token') + } + }) + + it('should return the claims from the id token as the loginIdentity', async () => { + const session = createSession() + const cs = await session.connectSession() + expect(cs).toBeDefined() + if (cs) { + expect(cs.loginIdentity).toStrictEqual({ + name: 'name-from-token', + email: 'email-address', + agencyCloudId: 'agency-cloud-id', + developerId: 'developer-id', + clientId: 'client-code', + adminId: 'market-admin', + userCode: 'user-code', + groups: 'groups', + orgName: 'org-name', + orgId: 'org-id', + offGroupIds: 'off-group-ids', + offGrouping: true, + offGroupName: 'off-group-name', + officeId: 'office-id', + orgProduct: 'org-product', + }) + } + }) + + it('should return a refreshToken of "donotuse"', async () => { + const session = createSession() + const cs = await session.connectSession() + expect(cs).toBeDefined() + if (cs) { + expect(cs.refreshToken).toBe('donotuse') + } + }) + }) + + describe('unauthorized', () => { + beforeEach(() => { + isAuthenticated.mockResolvedValue(false) + }) + it('should call connectAuthorizeRedirect()', async () => { + const session = createSession() + await session.connectSession() + expect(loginWithRedirect).toHaveBeenCalled() + }) + it('should make connectHasSession return false', async () => { + const session = createSession() + await session.connectSession() + expect(session.connectHasSession).toBe(false) + }) + }) + }) + }) +}) diff --git a/src/browser/__tests__/index.test.ts b/src/browser/__tests__/index.test.ts deleted file mode 100644 index 99b2a5f..0000000 --- a/src/browser/__tests__/index.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { FetchMock } from 'jest-fetch-mock' -import { ReapitConnectBrowserSession } from '../index' -import { mockTokenResponse, mockBrowserSession, createMockToken } from '../../__mocks__/session' -import { mockBrowserInitializers } from '../../__mocks__/session' -import { webcrypto } from 'crypto' - -jest.mock('idtoken-verifier', () => ({ - decode: (token: string) => { - return JSON.parse(token) - }, -})) - -jest.mock('../../utils/verify-decode-id-token') - -Object.defineProperties(global, { - crypto: { value: webcrypto, writable: true }, -}) - -const mockedFetch = fetch as FetchMock - -const getSession = () => - new ReapitConnectBrowserSession({ - ...mockBrowserInitializers, - }) - -describe('ReapitConnectBrowserSession', () => { - it('should correctly insantiate the class', () => { - const session = getSession() - expect(session instanceof ReapitConnectBrowserSession).toBe(true) - expect(session.connectAuthorizeRedirect).toBeDefined() - expect(session.connectLoginRedirect).toBeDefined() - expect(session.connectLogoutRedirect).toBeDefined() - expect(session.connectIsDesktop).toBeDefined() - expect(session.connectHasSession).toBeDefined() - }) - - it('should retrieve a session from memory and return as a session', async () => { - const session = getSession() - const validSession = Object.assign(session, { session: mockBrowserSession }) as ReapitConnectBrowserSession - const connectSession = await validSession.connectSession() - - expect(connectSession).toEqual(mockBrowserSession) - }) - - it('should have a connectClearSession that clears the current session', async () => { - const session = getSession() - const validSession = Object.assign(session, { session: mockBrowserSession }) as ReapitConnectBrowserSession - const connectSession = await validSession.connectSession() - - expect(connectSession).toEqual(mockBrowserSession) - - validSession.connectClearSession() - - const clearedSession = await validSession.connectSession() - - expect(clearedSession).toBeUndefined() - }) - - it('should return true from connectHasSession if session valid', () => { - const session = getSession() - const validSession = Object.assign(session, { session: mockBrowserSession }) as ReapitConnectBrowserSession - - expect(validSession.connectHasSession).toBe(true) - }) - - it('should return false from connectHasSession if session has expired', () => { - const expiredSession = { - ...mockBrowserSession, - accessToken: createMockToken({ exp: Math.round(new Date().getTime() / 1000) }), - } - - const session = getSession() - const invalidSession = Object.assign(session, { session: expiredSession }) as ReapitConnectBrowserSession - - expect(invalidSession.connectHasSession).toBe(false) - }) - - it('should return false from connectIsDesktop if desktop global is not present', () => { - const session = getSession() - - expect(session.connectIsDesktop).toBe(false) - }) - - it('should return true from connectIsDesktop if desktop global is present', () => { - Object.defineProperty(window, ReapitConnectBrowserSession.GLOBAL_KEY, { - value: {}, - writable: true, - }) - const session = getSession() - - expect(session.connectIsDesktop).toBe(true) - }) - - it('should return a internalRedirectUri from connectInternalRedirect if a state is present in the uri', async () => { - const code = 'SOME_CODE' - const internalRedirectUri = '/some-path?someQuery=true' - const nonce = 'MOCK_NONCE' - // In desktop mode, hence localstorage because tabs are junked. In a browser, it's session storage - window.localStorage.setItem(nonce, internalRedirectUri) - window.location.search = `?code=${code}&state=${nonce}` - const session = getSession() - - await session.connectSession() - expect(window.localStorage.getItem(nonce)).toEqual(internalRedirectUri) - expect(session.connectInternalRedirect).toEqual(internalRedirectUri) - }) - - it('should return null from connectInternalRedirect if a code is not present in the uri', async () => { - const pathName = '/some-alternative-path' - window.location.search = '' - window.location.pathname = pathName - const session = getSession() - - await session.connectSession() - - expect(session.connectInternalRedirect).toBeNull() - }) - - it('should refresh a session from a refresh token if session has expired', async () => { - // Not sure why but fetch mocking is sporadically failing because of a weird async issue in the jest setup - // hence this manual mock - window.fetch = jest.fn(() => { - return new Promise((resolve) => { - resolve({ - json: () => new Promise((resolve) => resolve(mockTokenResponse)), - } as Response) - }) - }) - - const expiredSession = { - ...mockBrowserSession, - accessToken: createMockToken({ exp: Math.round(new Date().getTime() / 1000) }), - } - - const session = getSession() - const invalidSession = Object.assign(session, { session: expiredSession }) as ReapitConnectBrowserSession - - const connectSession = await invalidSession.connectSession() - - expect(window.fetch).toHaveBeenCalledTimes(1) - expect(connectSession).toEqual(mockBrowserSession) - }) - - it('should refresh a session from a code if session has expired and code is in url', async () => { - const code = 'SOME_CODE' - const nonce = 'MOCK_NONCE' - - window.location.search = `?code=${code}&state=${nonce}` - // Not sure why but fetch mocking is sporadically failing because of a weird async issue in the jest setup - // hence this manual mock - window.fetch = jest.fn(() => { - return new Promise((resolve) => { - resolve({ - json: () => new Promise((resolve) => resolve(mockTokenResponse)), - } as Response) - }) - }) - - const expiredSession = { - ...mockBrowserSession, - accessToken: createMockToken({ exp: Math.round(new Date().getTime() / 1000) }), - refreshToken: '', - } - - const session = getSession() - const invalidSession = Object.assign(session, { session: expiredSession }) as ReapitConnectBrowserSession - - const connectSession = await invalidSession.connectSession() - - expect(window.fetch).toHaveBeenCalledTimes(1) - expect(connectSession).toEqual(mockBrowserSession) - }) - - it('should only call once to api and return undefined if already fetching', async () => { - const code = 'SOME_CODE' - const nonce = 'MOCK_NONCE' - - window.location.search = `?code=${code}&state=${nonce}` - - mockedFetch.mockResponseOnce(JSON.stringify(mockTokenResponse)) - - const session = getSession() - session.connectSession() - - const connectSession = await session.connectSession() - - expect(window.fetch).toHaveBeenCalledTimes(1) - expect(connectSession).toBeUndefined() - }) - - it('should redirect to the authorize endpoint if no refresh token or code', async () => { - window.location.search = '' - const mockedAuthEndpoint = jest.spyOn(ReapitConnectBrowserSession.prototype, 'connectAuthorizeRedirect') - - const session = getSession() - - await session.connectSession() - - expect(mockedAuthEndpoint).toHaveBeenCalledTimes(1) - }) - - it('should redirect to login page if token endpoint fails', async () => { - const mockedAuthEndpoint = jest.spyOn(ReapitConnectBrowserSession.prototype, 'connectAuthorizeRedirect') - const code = 'SOME_CODE' - const nonce = 'MOCK_NONCE' - - window.location.search = `?code=${code}&state=${nonce}` - - mockedFetch.mockResponseOnce(JSON.stringify({ error: 'Error from API' })) - - const session = getSession() - - await session.connectSession() - - expect(window.fetch).toHaveBeenCalledTimes(1) - expect(mockedAuthEndpoint).toHaveBeenCalledTimes(1) - }) - - it('should redirect to login if the method is called on session', async () => { - const mockedLoginEndpoint = jest.spyOn(ReapitConnectBrowserSession.prototype, 'connectLoginRedirect') - - const session = getSession() - - session.connectLoginRedirect() - - expect(mockedLoginEndpoint).toHaveBeenCalledTimes(1) - }) - - it('should redirect to logout if the method is called on session', async () => { - const mockedLoginEndpoint = jest.spyOn(ReapitConnectBrowserSession.prototype, 'connectLogoutRedirect') - const session = getSession() - - session.connectLogoutRedirect() - - expect(mockedLoginEndpoint).toHaveBeenCalledTimes(1) - }) - - it('should not timeout if user is in desktop mode', (done) => { - const mockedLogoutEndpoint = jest.spyOn(ReapitConnectBrowserSession.prototype, 'connectLogoutRedirect') - - new ReapitConnectBrowserSession({ - ...mockBrowserInitializers, - // Set the session inactivity timeout to zero - by default it is 3 hours - connectApplicationTimeout: 0, - }) - - // Trigger a mousemove event which starts the idle timer - const event = new MouseEvent('mousemove') - document.dispatchEvent(event) - - // Wrap the test in a timeout of 1ms because the logout is executed in the next tick of the event loop - setTimeout(() => { - expect(mockedLogoutEndpoint).not.toHaveBeenCalled() - done() - }, 1) - }) - - it('should redirect to logout if a user is idle and in web mode', (done) => { - window[ReapitConnectBrowserSession.GLOBAL_KEY] = null - const mockedLogoutEndpoint = jest.spyOn(ReapitConnectBrowserSession.prototype, 'connectLogoutRedirect') - - new ReapitConnectBrowserSession({ - ...mockBrowserInitializers, - // Set the session inactivity timeout to zero - by default it is 3 hours - connectApplicationTimeout: 0, - }) - - // Trigger a mousemove event which starts the idle timer - const event = new MouseEvent('mousemove') - document.dispatchEvent(event) - - // Wrap the test in a timeout of 1ms because the logout is executed in the next tick of the event loop - setTimeout(() => { - expect(mockedLogoutEndpoint).toHaveBeenCalledTimes(1) - done() - }, 1) - }) - - it('Should call fetch with post method', async () => { - const code = 'SOME_CODE' - const nonce = 'MOCK_NONCE' - - window.location.search = `?code=${code}&state=${nonce}` - mockedFetch.mockResponseOnce(JSON.stringify(mockTokenResponse)) - - const expiredSession = { - ...mockBrowserSession, - accessToken: createMockToken({ exp: Math.round(new Date().getTime() / 1000) }), - refreshToken: '', - } - - const session = getSession() - const invalidSession = Object.assign(session, { session: expiredSession }) as ReapitConnectBrowserSession - - await invalidSession.connectSession() - - expect(window.fetch).toHaveBeenLastCalledWith( - 'SOME_URL/token', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: expect.stringMatching(/(redirect_url|client_id|grant_type)/i), - }), - ) - }) - - it('Should call fetch with code challenge when PKCE = true', async () => { - const code = 'SOME_CODE' - const nonce = 'MOCK_NONCE' - - window.location.search = `?code=${code}&state=${nonce}` - mockedFetch.mockResponseOnce(JSON.stringify(mockTokenResponse)) - - const expiredSession = { - ...mockBrowserSession, - accessToken: createMockToken({ exp: Math.round(new Date().getTime() / 1000) }), - refreshToken: '', - } - - const session = new ReapitConnectBrowserSession({ - ...mockBrowserInitializers, - usePKCE: true, - }) - const invalidSession = Object.assign(session, { session: expiredSession }) as ReapitConnectBrowserSession - - await invalidSession.connectSession() - - expect(window.fetch).toHaveBeenLastCalledWith( - 'SOME_URL/token', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/x-www-form-urlencoded', - }), - body: expect.stringMatching(/(code_challenge|code_challenge_method)/i), - }), - ) - }) - - afterEach(() => { - jest.resetAllMocks() - window.localStorage.clear() - }) -}) diff --git a/src/browser/id-token.ts b/src/browser/id-token.ts new file mode 100644 index 0000000..05cc4af --- /dev/null +++ b/src/browser/id-token.ts @@ -0,0 +1,20 @@ +import { IdToken } from '@auth0/auth0-spa-js' +import { LoginIdentity } from '../types' + +export const idTokenToLoginIdentity = (claim: IdToken): LoginIdentity => ({ + name: claim['name'] || '', + email: claim['email'] || '', + agencyCloudId: claim['custom:reapit:agencyCloudId'] || null, + developerId: claim['custom:reapit:developerId'] || null, + clientId: claim['custom:reapit:clientCode'] || null, + adminId: claim['custom:reapit:marketAdmin'] || null, + userCode: claim['custom:reapit:userCode'] || null, + groups: claim['cognito:groups'] || [], + orgName: claim['custom:reapit:orgName'] || null, + orgId: claim['custom:reapit:orgId'] || null, + offGroupIds: claim['custom:reapit:offGroupIds'] || null, + offGrouping: claim['custom:reapit:offGrouping'] && claim['custom:reapit:offGrouping'] === 'true' ? true : false, + offGroupName: claim['custom:reapit:offGroupName'] || null, + officeId: claim['custom:reapit:officeId'] || null, + orgProduct: claim['custom:reapit:orgProduct'] || null, +}) diff --git a/src/browser/index.ts b/src/browser/index.ts index dfa5a34..a510730 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,64 +1,30 @@ -import 'isomorphic-fetch' -import { - ReapitConnectBrowserSessionInitializers, - ReapitConnectSession, - CoginitoAccess, - LoginIdentity, - CoginitoSession, -} from '../types' -import { connectSessionVerifyDecodeIdToken } from '../utils/verify-decode-id-token' -import decode from 'jwt-decode' -import { DecodedToken } from '../utils' -import { v4 as uuid } from 'uuid' -import { TextEncoder } from 'text-encoding' -import { Sha256 } from '@aws-crypto/sha256-browser' +import { ReapitConnectBrowserSessionInitializers, ReapitConnectSession } from '../types' -type BasePayload = { - redirect_uri: string - client_id: string -} - -type AuthCodePayload = BasePayload & { - grant_type: 'authorization_code' - code: string - code_verifier?: string - code_challenge_method?: 'S256' -} - -type RefreshTokenPayload = BasePayload & { - grant_type: 'refresh_token' - refresh_token: string -} - -const dec2hex = (dec: number): string => ('0' + dec.toString(16)).substr(-2) +import { Auth0Client, RedirectLoginResult } from '@auth0/auth0-spa-js' +import { sessionStorageCache } from './session-storage-cache' +import { idTokenToLoginIdentity } from './id-token' -const genCodeVerifier = (): string => { - const array = new Uint32Array(56) - window.crypto.getRandomValues(array) - return Array.from(array, dec2hex).join('') -} +type AppState = Partial<{ + internalRedirectPath: string +}> export class ReapitConnectBrowserSession { - // Static constants public static readonly GLOBAL_KEY = '__REAPIT_MARKETPLACE_GLOBALS__' public static readonly REFRESH_TOKEN_KEY = 'REAPIT_REFRESH_TOKEN' public static readonly USER_NAME_KEY = 'REAPIT_LAST_AUTH_USER' public static readonly CODE_VERIFIER = 'REAPIT_CODE_VERIFIER' public static readonly STATE_NONCE = 'REAPIT_STATE_NONCE' public static readonly APP_DEFAULT_TIMEOUT = 10800000 // 3hrs in ms - - // Private cached variables, I don't want users to reference these directly or it will get confusing. - // and cause bugs - private connectOAuthUrl: string - private connectClientId: string - private session: ReapitConnectSession | null - private connectLoginRedirectPath: string - private connectLogoutRedirectPath: string + public connectInternalRedirect: string | null + private auth0Client: Auth0Client + private returnTo: string + private redirect_uri: string private connectApplicationTimeout: number + public isAuthenticated: boolean = false private idleTimeoutCountdown: number - private refreshTokenStorage: Storage - private fetching: boolean - private readonly usePKCE: boolean + private forceRefetch: boolean = false + private connectClientId: string + private connectOAuthUrl: string constructor({ connectClientId, @@ -68,19 +34,36 @@ export class ReapitConnectBrowserSession { connectApplicationTimeout, usePKCE = true, }: ReapitConnectBrowserSessionInitializers) { - // Instantiate my private variables from the constructor params - this.connectOAuthUrl = connectOAuthUrl - this.connectClientId = connectClientId - this.connectLoginRedirectPath = `${window.location.origin}${connectLoginRedirectPath || ''}` - this.connectLogoutRedirectPath = `${window.location.origin}${ - connectLogoutRedirectPath || connectLogoutRedirectPath === '' ? connectLogoutRedirectPath : '/login' - }` + if (!usePKCE) { + console.info('PKCE requested to be disabled but is now always used.') + } + this.connectApplicationTimeout = connectApplicationTimeout ?? ReapitConnectBrowserSession.APP_DEFAULT_TIMEOUT - this.refreshTokenStorage = this.connectIsDesktop ? window.localStorage : window.sessionStorage - this.fetching = false - this.session = null this.idleTimeoutCountdown = this.connectApplicationTimeout - this.usePKCE = usePKCE + this.redirect_uri = `${window.location.origin}${connectLoginRedirectPath || ''}` + this.returnTo = `${window.location.origin}${ + connectLogoutRedirectPath || connectLogoutRedirectPath === '' ? connectLogoutRedirectPath : '/login' + }` + + this.connectClientId = connectClientId + this.connectOAuthUrl = connectOAuthUrl + + this.connectInternalRedirect = null + this.auth0Client = new Auth0Client({ + clientId: connectClientId, + domain: connectOAuthUrl, + authorizationParams: { + redirect_uri: this.redirect_uri, + }, + useRefreshTokens: true, + + // use session storage provider if in AC, otherwise fall back to configured cache location + cache: this.connectIsDesktop ? sessionStorageCache : undefined, + cacheLocation: 'localstorage', + }) + + this.isAuthenticated = false + this.connectBindPublicMethods() this.setIdleTimeoutListeners() } @@ -108,272 +91,96 @@ export class ReapitConnectBrowserSession { document.ontouchstart = resetTimer } - private get refreshToken(): string | null { - return ( - this.session?.refreshToken ?? - this.refreshTokenStorage.getItem( - `${ReapitConnectBrowserSession.REFRESH_TOKEN_KEY}_${this.userName}_${this.connectClientId}`, - ) - ) - } - - private get userName(): string | null { - return ( - this.session?.loginIdentity.email ?? - this.refreshTokenStorage.getItem(`${ReapitConnectBrowserSession.USER_NAME_KEY}_${this.connectClientId}`) - ) - } - - private async encryptCodeVerifier(code_verifier: string): Promise { - const encoder = new TextEncoder() - const data = encoder.encode(code_verifier) - const hash = new Sha256() - hash.update(data) - const digest = await hash.digest() - - return btoa(String.fromCharCode.apply(null, [...new Uint8Array(digest)])) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - } - - private codeVerifierStorageKey(state: string): string { - return `${state}-${ReapitConnectBrowserSession.CODE_VERIFIER}` - } - - private codeVerifier(state: string): string { - const codeVerifier = this.refreshTokenStorage.getItem(this.codeVerifierStorageKey(state)) - - if (codeVerifier) return codeVerifier - - const code = genCodeVerifier() - - this.setCodeVerifier({ state, code }) - - return code - } - - private setCodeVerifier({ code, state }: { code: string; state: string }) { - this.refreshTokenStorage.setItem(this.codeVerifierStorageKey(state), code) - } - - private setRefreshToken(session: ReapitConnectSession) { - if (session.refreshToken && session.loginIdentity && session.loginIdentity.email) { - this.refreshTokenStorage.setItem( - `${ReapitConnectBrowserSession.REFRESH_TOKEN_KEY}_${session.loginIdentity.email}_${this.connectClientId}`, - session.refreshToken, - ) - } - if (session.loginIdentity && session.loginIdentity.email) { - this.refreshTokenStorage.setItem( - `${ReapitConnectBrowserSession.USER_NAME_KEY}_${this.connectClientId}`, - session.loginIdentity.email, - ) - } - } - - private clearRefreshToken() { - this.refreshTokenStorage.removeItem( - `${ReapitConnectBrowserSession.REFRESH_TOKEN_KEY}_${this.userName}_${this.connectClientId}`, - ) - this.refreshTokenStorage.removeItem(`${ReapitConnectBrowserSession.USER_NAME_KEY}_${this.connectClientId}`) - } - - // Check on access token to see if has expired - they last 1hr only before I need to refresh - private get sessionExpired() { - if (this.session) { - const decoded = decode>(this.session.accessToken) - const expiry = decoded['exp'] - const fiveMinsFromNow = Math.round(new Date().getTime() / 1000) + 300 - return expiry ? expiry < fiveMinsFromNow : true - } - - return true - } - - // Gets the auth code from the url of the given page - private get authCode(): string | null { - const params = new URLSearchParams(window.location.search) - const authorizationCode = params.get('code') - - return authorizationCode || null - } - - // Calls the token endpoint in Cognito with either a refresh token or a code, depending on what - // I have available in local storage or in the URL. - // See: https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html - private async connectGetSession( - url: string, - payload: AuthCodePayload | RefreshTokenPayload, - ): Promise { - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(payload).toString(), - } as RequestInit) - const session: CoginitoSession | undefined = await response.json() - - if (!session || (session && session.error)) return this.handleError('Error fetching session from Reapit Connect') - - // I need to verify the identity claims I have just received from the server dwdqd - const loginIdentity: LoginIdentity | undefined = await connectSessionVerifyDecodeIdToken(session.id_token) - - // If the idToken is invalid, don't return the session - if (!loginIdentity) return this.handleError('Login identity was not verified') - - const { access_token, refresh_token, id_token } = session - - return { - accessToken: access_token, - // I only get a new refresh token back when grant type is code. I only use grant type code - // when I don't have a session, so I can update the refresh token for code and when I have a - // session and am refereshing, I can recycle the old refresh token - refreshToken: refresh_token ? refresh_token : this.session ? this.session?.refreshToken : '', - idToken: id_token, - loginIdentity, - } - } catch (err) { - return this.handleError(`Reapit Connect Token Error ${(err as any).message}`) - } - } - - private handleError(error: string | Error) { - this.clearRefreshToken() - typeof error === 'string' ? console.error('Reapit Connect Error:', error) : console.error(error) - } - - // set a redirect URI to my page where I instantiated the flow, by decoding the state object - public get connectInternalRedirect() { - const params = new URLSearchParams(window.location.search) - const stateNonce = params.get('state') - - if (!stateNonce) return null - - const internalRedirectString = this.refreshTokenStorage.getItem(stateNonce) - - if (internalRedirectString) { - return decodeURIComponent(internalRedirectString) - } - return null - } - - // A convenience getter to check if my app has been loaded inside RPS / Desktop / Agency Cloud - public get connectIsDesktop() { + public get connectIsDesktop(): boolean { return Boolean(window[ReapitConnectBrowserSession.GLOBAL_KEY]) } - // A convenience getter to check if my app has a valid session - public get connectHasSession() { - return Boolean(this.session && !this.sessionExpired) + public get connectHasSession(): boolean { + return this.isAuthenticated } - // Handles redirect to authorization endpoint - in most cases, I don't need to call in my app - // but made public if I want to override the redirect URI I specified in the constructor public async connectAuthorizeRedirect(redirectUri?: string): Promise { - const authRedirectUri = redirectUri || this.connectLoginRedirectPath const params = new URLSearchParams(window.location.search) params.delete('code') const search = params ? `?${params.toString()}` : '' - const internalRedirectPath = encodeURIComponent(`${window.location.pathname}${search}`) - const stateNonce = uuid() - this.refreshTokenStorage.setItem(stateNonce, internalRedirectPath) - const code_challenge = await this.encryptCodeVerifier(this.codeVerifier(stateNonce)) - - let location = `${this.connectOAuthUrl}/authorize?response_type=code&client_id=${this.connectClientId}&redirect_uri=${authRedirectUri}&state=${stateNonce}` - if (this.usePKCE) location += `&code_challenge_method=S256&code_challenge=${code_challenge}` + const internalRedirectPath = `${window.location.pathname}${search}` - window.location.href = location + await this.auth0Client.loginWithRedirect({ + appState: { + internalRedirectPath, + }, + authorizationParams: { + redirect_uri: redirectUri, + }, + }) } - // Handles redirect to login - defaults to constructor redirect uri but I can override if I like. - // Used as handler for login page button public connectLoginRedirect(redirectUri?: string): void { - const loginRedirectUri = redirectUri || this.connectLoginRedirectPath - this.clearRefreshToken() - window.location.href = `${this.connectOAuthUrl}/login?response_type=code&client_id=${this.connectClientId}&redirect_uri=${loginRedirectUri}` + this.connectAuthorizeRedirect(redirectUri) } - // Handles redirect to logout - defaults to constructor login uri but I can override if I like. - // Used as handler for logout menu button public connectLogoutRedirect(redirectUri?: string): void { - const logoutRedirectUri = redirectUri || this.connectLogoutRedirectPath - this.clearRefreshToken() - window.location.href = `${this.connectOAuthUrl}/logout?client_id=${this.connectClientId}&logout_uri=${logoutRedirectUri}` + this.auth0Client.logout({ + openUrl: false, + }) + + window.location.href = + this.connectOAuthUrl + + '/oidc/logout?' + + new URLSearchParams({ + post_logout_redirect_uri: redirectUri || this.returnTo, + client_id: this.connectClientId, + }) } public connectClearSession(): void { - this.session = null + this.forceRefetch = true } - // The main method for fetching a session in an app. - public async connectSession(): Promise { - // Ideally, if I have a valid session, just return it - if (!this.sessionExpired) { - return this.session as ReapitConnectSession + private async currentSession(): Promise { + if (!(await this.auth0Client.isAuthenticated())) { + throw new Error('unauthenticated') } - // Stops me from making multiple calls to the token endpoint - if (this.fetching) { - return - } + const accessToken = await this.auth0Client.getTokenSilently({ + cacheMode: this.forceRefetch ? 'off' : 'on', + }) - // I don't want to make more requests while I am in the OAuth Flow - this.fetching = true + this.forceRefetch = false - try { - // See comment in connectGetSession method. If I have a refresh token, I want to use this in the - // first instance - get the refresh endpoint. Otherwise check to see if I have a code and get - // the code endpoint so I can exchange for a token - const endpoint = `${this.connectOAuthUrl}/token` - - // I don't have either a refresh token or a code so redirect to the authorization endpoint to get - // a code I can exchange for a token - if (!this.refreshToken && !this.authCode) { - return this.connectAuthorizeRedirect() - } + const idToken = await this.auth0Client.getIdTokenClaims() + if (!idToken) { + throw new Error('id token not present') + } - const qs = new URLSearchParams(window.location.search) - const state = qs.get('state') + return { + accessToken, + idToken: idToken?.__raw, + loginIdentity: idTokenToLoginIdentity(idToken), + refreshToken: 'donotuse', + } + } - const payload: AuthCodePayload | RefreshTokenPayload = this.refreshToken - ? { - redirect_uri: this.connectLoginRedirectPath, - client_id: this.connectClientId, - grant_type: 'refresh_token', - refresh_token: this.refreshToken, - } - : { - redirect_uri: this.connectLoginRedirectPath, - client_id: this.connectClientId, - grant_type: 'authorization_code', - code: this.authCode as string, - } + private handledCodes: Record>> = {} - if (!this.refreshToken && this.usePKCE) { - payload['code_verifier'] = this.codeVerifier(state as string) - payload['code_challenge_method'] = 'S256' + public async connectSession(): Promise { + const code = new URLSearchParams(window.location.search).get('code') + if (code) { + if (!this.handledCodes[code]) { + this.handledCodes[code] = this.auth0Client.handleRedirectCallback() } - // Get a new session from the code or refresh token - const session = await this.connectGetSession(endpoint, payload) - - this.fetching = false - - if (session) { - // Cache the session in memory for future use then return it to the user - this.session = - !session.refreshToken && this.refreshToken ? { ...session, refreshToken: this.refreshToken } : session - this.setRefreshToken(session) - return this.session - } + const { appState } = await this.handledCodes[code] + this.connectInternalRedirect = appState?.internalRedirectPath || null + } - // The token endpoint failed to get a session so send me to login to get a new session - this.connectAuthorizeRedirect() - } catch (err) { - return this.handleError(`Reapit Connect Session error ${(err as any).message}`) + try { + const session = await this.currentSession() + this.isAuthenticated = true + return session + } catch { + this.isAuthenticated = false + await this.connectAuthorizeRedirect() } } } diff --git a/src/browser/session-storage-cache.ts b/src/browser/session-storage-cache.ts new file mode 100644 index 0000000..f312026 --- /dev/null +++ b/src/browser/session-storage-cache.ts @@ -0,0 +1,19 @@ +export const sessionStorageCache = { + get: function (key: string) { + const v = sessionStorage.getItem(key) + return v ? JSON.parse(v) : undefined + }, + + set: function (key: string, value: any) { + sessionStorage.setItem(key, JSON.stringify(value)) + }, + + remove: function (key: string) { + sessionStorage.removeItem(key) + }, + + // Optional + allKeys: function () { + return Object.keys(sessionStorage) + }, +} diff --git a/src/index.ts b/src/index.ts index dad6279..5c6958c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ export * from './browser' export * from './react' export * from './types' -export * from './utils' diff --git a/src/react/__tests__/index.test.tsx b/src/react/__tests__/index.test.tsx index 5379a3a..75f9d3b 100644 --- a/src/react/__tests__/index.test.tsx +++ b/src/react/__tests__/index.test.tsx @@ -2,7 +2,10 @@ import { renderHook } from '@testing-library/react-hooks' import { useReapitConnect } from '../index' import { ReapitConnectHook } from '../../types' import { ReapitConnectBrowserSession } from '../../browser' -import { mockBrowserSession } from '../../__mocks__/session' + +const mockBrowserSession = { + aSession: 'here', +} jest.mock('../../browser/index', () => ({ ReapitConnectBrowserSession: jest.fn(() => ({ diff --git a/src/utils/__mocks__/verify-decode-id-token.ts b/src/utils/__mocks__/verify-decode-id-token.ts deleted file mode 100644 index f4a8e49..0000000 --- a/src/utils/__mocks__/verify-decode-id-token.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { mockLoginIdentity } from '../../__mocks__/session' - -export const connectSessionVerifyDecodeIdToken = () => new Promise((resolve) => resolve(mockLoginIdentity)) - -export const connectSessionVerifyDecodeIdTokenWithPublicKeys = () => - new Promise((resolve) => resolve(mockLoginIdentity)) diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index b633838..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './verify-decode-id-token' diff --git a/src/utils/verify-decode-id-token.ts b/src/utils/verify-decode-id-token.ts deleted file mode 100644 index 0483153..0000000 --- a/src/utils/verify-decode-id-token.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* istanbul ignore file */ -// Not possible to test this file without stubbing public keys. Obviously can't include these in the -// project for security reasons and using random strings would be basically worthless as a test. -// Given code comes from AWS, seems reasonable to trust the implementation. -import 'isomorphic-fetch' -import { LoginIdentity } from '../types' -// We wanted to use idtoken-verifier, currently using bashleigh-idtoken-verifier -// as the types are incorrect in root package -import IdTokenVerifier from 'idtoken-verifier' -import decode from 'jwt-decode' - -// Util to verify integrity of AWS tokens for client side applications. Allows Connect Session module to check a -// ID Token for validity of claims. See Connect Session for usage, not intended for external users. -// See AWS Docs https://github.com/awslabs/aws-support-tools/tree/master/Cognito/decode-verify-jwt), code adapted from here. - -interface Claim { - token_use: string - auth_time: number - iss: string - exp: number - username: string - client_id: string -} - -export type DecodedToken = { - aud: string -} & T - -export const connectSessionVerifyDecodeIdTokenWithPublicKeys = async ( - token: string, -): Promise => { - try { - const decodedToken = decode>(token) - const aud: string | string[] = decodedToken.aud - - const verifier = new IdTokenVerifier({ - issuer: decodedToken.iss, - audience: Array.isArray(aud) ? aud[0] : aud, - leeway: 300, - }) - - const claim = (await new Promise((resolve, reject) => - verifier.verify(token, (err: Error | null, payload: object | null) => { - if (err) { - reject(err) - } - resolve(payload as Claim) - }), - )) as Claim - const currentSeconds = Math.floor(new Date().valueOf() / 1000) - - // Allow 5 minutes to avoid CPU clock latency issues. See: https://github.com/reapit/foundations/issues/2467 - // basically some Windows laptops calculate time not terribly accurately and can be out by as much as 2 or 3 mins - // based on testing so the currentSeconds are before the auth_time in AWS. - // Not ideal but prevents constant invalid id_token messages - if (currentSeconds > claim.exp + 300 || currentSeconds + 300 < claim.auth_time) - throw new Error('Id verification claim expired') - if (claim.token_use !== 'id') throw new Error('Id verification claim is not an id token') - - return { - name: claim['name'], - email: claim['email'], - agencyCloudId: claim['custom:reapit:agencyCloudId'] || null, - developerId: claim['custom:reapit:developerId'] || null, - clientId: claim['custom:reapit:clientCode'] || null, - adminId: claim['custom:reapit:marketAdmin'] || null, - userCode: claim['custom:reapit:userCode'] || null, - groups: claim['cognito:groups'] || [], - orgName: claim['custom:reapit:orgName'] || null, - orgId: claim['custom:reapit:orgId'] || null, - offGroupIds: claim['custom:reapit:offGroupIds'] || null, - offGrouping: claim['custom:reapit:offGrouping'] && claim['custom:reapit:offGrouping'] === 'true' ? true : false, - offGroupName: claim['custom:reapit:offGroupName'] || null, - officeId: claim['custom:reapit:officeId'] || null, - orgProduct: claim['custom:reapit:orgProduct'] || null, - } - } catch (error) { - console.error('Reapit Connect Session error:', (error as any).message) - } -} - -export const connectSessionVerifyDecodeIdToken = async (token: string): Promise => { - return connectSessionVerifyDecodeIdTokenWithPublicKeys(token) -} diff --git a/yarn.lock b/yarn.lock index 0d9bc5c..b3996ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,6 +47,11 @@ jsonpointer "^5.0.0" leven "^3.1.0" +"@auth0/auth0-spa-js@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@auth0/auth0-spa-js/-/auth0-spa-js-2.1.3.tgz#aabf6f439e41edbeef0cf4766ad754e5b47616e5" + integrity sha512-NMTBNuuG4g3rame1aCnNS5qFYIzsTUV5qTFPRfTyYFS1feS6jsCBR+eTq9YkxCp1yuoM2UIcjunPaoPl77U9xQ== + "@aws-cdk/asset-awscli-v1@^2.2.202": version "2.2.205" resolved "https://registry.yarnpkg.com/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.205.tgz#ac25d541dd3c34e2aae6789d1bbfccdfd57b8210" @@ -100,7 +105,7 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-crypto/sha256-browser@5.2.0", "@aws-crypto/sha256-browser@^5.2.0": +"@aws-crypto/sha256-browser@5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz#153895ef1dba6f9fce38af550e0ef58988eb649e" integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== @@ -3656,11 +3661,6 @@ dependencies: "@babel/types" "^7.20.7" -"@types/base-64@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@types/base-64/-/base-64-1.0.2.tgz#f7bc80d242306f20c57f076d79d1efe2d31032ca" - integrity sha512-uPgKMmM9fmn7I+Zi6YBqctOye4SlJsHKcisjHIMWpb2YKZRc36GpKyNuQ03JcT+oNXg1m7Uv4wU94EVltn8/cw== - "@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" @@ -3754,11 +3754,6 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== -"@types/text-encoding@^0": - version "0.0.39" - resolved "https://registry.yarnpkg.com/@types/text-encoding/-/text-encoding-0.0.39.tgz#6f6436ceb843d96a2306a87dc7e2286e62c2177c" - integrity sha512-gRPvgL1aMgP6Pv92Rs310cJvVQ86DSF62E7K30g1FoGmmYWXoNuXT8PV835iAVeiAZkRwr2IW37KuyDn9ljmeA== - "@types/tough-cookie@*": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" @@ -4223,15 +4218,6 @@ aws-sdk@^2.1580.0: uuid "8.0.0" xml2js "0.6.2" -axios@^1.7.7: - version "1.7.7" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" - integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== - dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.0" - proxy-from-env "^1.1.0" - babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -4337,12 +4323,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base-64@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/base-64/-/base-64-1.0.0.tgz#09d0f2084e32a3fd08c2475b973788eee6ae8f4a" - integrity sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg== - -base64-js@^1.0.2, base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@^1.0.2, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -4562,9 +4543,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001663: - version "1.0.30001667" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz#99fc5ea0d9c6e96897a104a8352604378377f949" - integrity sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw== + version "1.0.30001680" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz" + integrity sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA== case@1.6.3: version "1.6.3" @@ -4872,11 +4853,6 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" -crypto-js@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" - integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== - crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" @@ -5371,11 +5347,6 @@ es6-error@^4.1.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -es6-promise@^4.2.8: - version "4.2.8" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" - integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== - esbuild@^0.21.3: version "0.21.5" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" @@ -5745,11 +5716,6 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== -follow-redirects@^1.15.6: - version "1.15.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== - for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -6206,18 +6172,6 @@ idb@^7.0.1: resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== -idtoken-verifier@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/idtoken-verifier/-/idtoken-verifier-2.2.4.tgz#5749bd3fc9b757db40ad764484173584fb19fb55" - integrity sha512-5t7O8cNHpJBB8FnwLD0qFZqy/+qGICObQKUl0njD6vXKHhpZPLEe8LU7qv/GBWB3Qv5e/wAIFHYVi4SoQwdOxQ== - dependencies: - base64-js "^1.5.1" - crypto-js "^4.2.0" - es6-promise "^4.2.8" - jsbn "^1.1.0" - unfetch "^4.2.0" - url-join "^4.0.1" - ieee754@1.1.13: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" @@ -7078,11 +7032,6 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -jsbn@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" - integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== - jsdom@^20.0.0: version "20.0.3" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-20.0.3.tgz#886a41ba1d4726f67a8858028c99489fed6ad4db" @@ -7189,11 +7138,6 @@ jsonschema@^1.4.1: object.assign "^4.1.4" object.values "^1.1.6" -jwt-decode@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" - integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== - keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -8729,16 +8673,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8833,14 +8768,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8999,11 +8927,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-encoding@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643" - integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA== - text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -9241,11 +9164,6 @@ undici@^5.25.4: dependencies: "@fastify/busboy" "^2.0.0" -unfetch@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be" - integrity sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA== - unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" @@ -9321,11 +9239,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -url-join@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" - integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== - url-parse@^1.5.3: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -9813,16 +9726,7 @@ workbox-window@7.1.0, workbox-window@^7.0.0: "@types/trusted-types" "^2.0.2" workbox-core "7.1.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==