diff --git a/cypress/fixtures/auth.token.json b/cypress/fixtures/auth.token.json index cb20356ac..08ea815cd 100644 --- a/cypress/fixtures/auth.token.json +++ b/cypress/fixtures/auth.token.json @@ -1,7 +1,7 @@ { "access_token": "access_token", - "expires_in": 43200000, - "refresh_expires_in": 900, + "expires_in": 600, + "refresh_expires_in": 1800, "refresh_token": "refresh_token", "token_type": "bearer", "not-before-policy": 0, diff --git a/cypress/integration/tokens.spec.ts b/cypress/integration/tokens.spec.ts new file mode 100644 index 000000000..b32f21524 --- /dev/null +++ b/cypress/integration/tokens.spec.ts @@ -0,0 +1,132 @@ +import { RENEW_BEFORE_EXPIRY_IN_MS } from '../../src/components/auth/auth'; +import { getTokenExpiryFromLocalStorage } from '../../src/components/sessionCookie/accessSessionLocalStorage'; + +const waitForTokenProcessing = () => { + // TODO: don't arbitrarily wait for token to be processed, find some + // way to cleanly determine that the token was processed instead. + // possible candidates are spying on `localStorage` or `setTimeout` + return cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting +}; + +describe('Keycloak Tokens', () => { + let authTokenJson; + before(() => { + cy.fixture('auth.token.json').then((fixture) => { + authTokenJson = fixture; + }); + }); + + it('should get and store tokens and expiry time on login', () => { + cy.caritasMockedLogin(); + + cy.get('#appRoot').then(() => { + cy.getCookie('keycloak').should('exist'); + cy.getCookie('refreshToken').should('exist'); + + const tokenExpiry = getTokenExpiryFromLocalStorage(); + expect(tokenExpiry.accessTokenValidUntilTime).to.exist; + expect(tokenExpiry.refreshTokenValidUntilTime).to.exist; + }); + }); + + it('should keep refreshing access token before it expires', () => { + cy.clock(); + cy.caritasMockedLogin(); + + for (let check = 0; check < 3; check++) { + waitForTokenProcessing(); + + cy.tick( + authTokenJson.expires_in * 1000 - RENEW_BEFORE_EXPIRY_IN_MS + ); + cy.wait('@authToken').then((interception) => { + expect(interception.request.body).to.include( + 'grant_type=refresh_token' + ); + }); + } + }); + + it('should refresh the access token if its expired when loading the app', () => { + cy.clock(); + cy.caritasMockedLogin(); + + cy.clock().then((clock) => { + clock.restore(); + }); + cy.clock(authTokenJson.expires_in * 1000 + 1); + cy.reload(); + cy.get('#appRoot'); + + cy.wait('@authToken').then((interception) => { + expect(interception.request.body).to.include( + 'grant_type=refresh_token' + ); + }); + + cy.tick(1000); // logout() call uses setTimeout + cy.get('#appRoot').should('exist'); + }); + + it('should logout if refresh token is already expired when loading the app', () => { + cy.clock(); + cy.caritasMockedLogin(); + + cy.clock().then((clock) => { + clock.restore(); + }); + cy.clock(authTokenJson.refresh_expires_in * 1000 + 1); + cy.reload(); + cy.get('#appRoot'); + waitForTokenProcessing(); + + cy.tick(1000); // logout() call uses setTimeout + cy.get('#loginRoot').should('exist'); + }); + + it('should logout if refresh token is expired while the app is loaded', () => { + cy.clock(); + cy.caritasMockedLogin(); + waitForTokenProcessing(); + + cy.tick(authTokenJson.refresh_expires_in * 1000 + 1); + waitForTokenProcessing(); + + cy.tick(1000); // logout() call uses setTimeout + cy.get('#loginRoot').should('exist'); + }); + + it('should not logout if refresh token is expired but access token is still valid', () => { + cy.clock(); + cy.caritasMockedLogin({ + auth: { expires_in: 1800, refresh_expires_in: 600 } + }); + + waitForTokenProcessing(); + cy.tick(600 * 1000); + waitForTokenProcessing(); + + cy.tick(1000); // logout() call uses setTimeout + cy.get('#loginRoot').should('not.exist'); + }); + + it('should not logout if refresh token is expired but access token is still valid when the app loads', () => { + const refreshExpiresIn = 600; + + cy.clock(); + cy.caritasMockedLogin({ + auth: { expires_in: 1800, refresh_expires_in: refreshExpiresIn } + }); + + cy.clock().then((clock) => { + clock.restore(); + }); + cy.clock(refreshExpiresIn * 1000 + 1); + cy.reload(); + cy.get('#appRoot'); + waitForTokenProcessing(); + + cy.tick(1000); // logout() call uses setTimeout + cy.get('#loginRoot').should('not.exist'); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 41824d635..7e0a97852 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -24,8 +24,8 @@ afterEach(() => { // - https://github.com/cypress-io/cypress/issues/9170 // - https://github.com/cypress-io/cypress/issues/9362 // - https://github.com/cypress-io/cypress/issues/8926 - cy.clearCookies(); cy.window().then((win) => (win.location.href = 'about:blank')); + cy.clearCookies(); }); Cypress.Commands.add( @@ -81,7 +81,7 @@ Cypress.Commands.add( cy.intercept('GET', config.endpoints.userSessions, { fixture: 'service.users.sessions.askers.json' }); - cy.intercept('GET', config.endpoints.liveservice, { + cy.intercept('GET', `${config.endpoints.liveservice}/**/*`, { fixture: 'service.live.info.json' }); cy.intercept('POST', config.endpoints.rocketchatAccessToken, { @@ -102,6 +102,6 @@ Cypress.Commands.add( }); cy.get('.button__primary').click(); cy.wait('@authToken'); - cy.get('#appRoot'); + cy.get('#appRoot').should('exist'); } ); diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 2f360ff10..43a68eaea 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -22,6 +22,7 @@ import { import { ContextProvider } from '../../globalState/state'; import { getUserData } from '../apiWrapper'; import { Loading } from './Loading'; +import { handleTokenRefresh } from '../auth/auth'; import { logout } from '../logout/logout'; import '../../resources/styles/styles'; import './app.styles'; @@ -75,20 +76,22 @@ export const App: React.FC = () => { if (!userDataRequested) { setUserDataRequested(true); - getUserData() - .then((userProfileData: UserDataInterface) => { - // set informal / formal cookie depending on the given userdata - setTokenInCookie( - 'useInformal', - !userProfileData.formalLanguage ? '1' : '' - ); - setUserData(userProfileData); - setAppReady(true); - }) - .catch((error) => { - window.location.href = config.endpoints.logoutRedirect; - console.log(error); - }); + handleTokenRefresh().then(() => { + getUserData() + .then((userProfileData: UserDataInterface) => { + // set informal / formal cookie depending on the given userdata + setTokenInCookie( + 'useInformal', + !userProfileData.formalLanguage ? '1' : '' + ); + setUserData(userProfileData); + setAppReady(true); + }) + .catch((error) => { + window.location.href = config.endpoints.logoutRedirect; + console.log(error); + }); + }); } useEffect(() => { diff --git a/src/components/auth/auth.ts b/src/components/auth/auth.ts new file mode 100644 index 000000000..1b58648fb --- /dev/null +++ b/src/components/auth/auth.ts @@ -0,0 +1,109 @@ +import { logout } from '../logout/logout'; +import { LoginData } from '../registration/autoLogin'; +import { setTokenInCookie } from '../sessionCookie/accessSessionCookie'; +import { + getTokenExpiryFromLocalStorage, + setTokenExpiryInLocalStorage +} from '../sessionCookie/accessSessionLocalStorage'; +import { refreshKeycloakAccessToken } from '../sessionCookie/refreshKeycloakAccessToken'; + +export const RENEW_BEFORE_EXPIRY_IN_MS = 10 * 1000; // seconds + +export const setTokens = (data: LoginData) => { + if (data.access_token) { + setTokenInCookie('keycloak', data.access_token); + setTokenExpiryInLocalStorage( + 'auth.access_token_valid_until', + data.expires_in + ); + } + if (data.refresh_token) { + setTokenInCookie('refreshToken', data.refresh_token); + setTokenExpiryInLocalStorage( + 'auth.refresh_token_valid_until', + data.refresh_expires_in + ); + } +}; + +const refreshTokens = (): Promise => { + const currentTime = new Date().getTime(); + const tokenExpiry = getTokenExpiryFromLocalStorage(); + + if ( + tokenExpiry.refreshTokenValidUntilTime <= + currentTime - RENEW_BEFORE_EXPIRY_IN_MS + ) { + logout(true); + return Promise.resolve(); + } + + return refreshKeycloakAccessToken().then((response) => { + setTokens(response); + }); +}; + +const startTimers = ({ + accessTokenValidInMs, + refreshTokenValidInMs +}: { + accessTokenValidInMs: number; + refreshTokenValidInMs: number; +}) => { + const accessTokenRefreshIntervalInMs = + accessTokenValidInMs - RENEW_BEFORE_EXPIRY_IN_MS; + + let refreshInterval; + // just a sanity check so that we don't accidentally register an endless loop + if (accessTokenRefreshIntervalInMs > 0) { + refreshInterval = window.setInterval(() => { + refreshTokens(); + }, accessTokenRefreshIntervalInMs); + } + + if (refreshTokenValidInMs > accessTokenValidInMs) { + // when refresh token is longer valid than access token we need to + // logout if the refresh token expires + window.setTimeout(() => { + if (refreshInterval) { + window.clearInterval(refreshInterval); + } + + logout(true); + }, refreshTokenValidInMs); + } +}; + +export const handleTokenRefresh = (): Promise => { + return new Promise((resolve) => { + const currentTime = new Date().getTime(); + const tokenExpiry = getTokenExpiryFromLocalStorage(); + const accessTokenValidInMs = + tokenExpiry.accessTokenValidUntilTime - currentTime; + + const refreshTokenValidInMs = + tokenExpiry.refreshTokenValidUntilTime - currentTime; + + if (refreshTokenValidInMs <= 0 && accessTokenValidInMs <= 0) { + // access token and refresh token no longer valid, logout + logout(true); + resolve(); + } else if (accessTokenValidInMs <= 0) { + // access token no longer valid but refresh token still valid, refresh tokens + refreshTokens().then(() => { + startTimers({ + accessTokenValidInMs, + refreshTokenValidInMs + }); + resolve(); + }); + } else { + // access token and refresh token still valid, just start the timers + startTimers({ + accessTokenValidInMs, + refreshTokenValidInMs + }); + resolve(); + } + }); +}; diff --git a/src/components/logout/logout.ts b/src/components/logout/logout.ts index 6459ee2ea..18db22176 100644 --- a/src/components/logout/logout.ts +++ b/src/components/logout/logout.ts @@ -2,6 +2,7 @@ import { config } from '../../resources/scripts/config'; import { removeAllCookies } from '../sessionCookie/accessSessionCookie'; import { rocketchatLogout } from '../apiWrapper'; import { keycloakLogout } from '../apiWrapper'; +import { removeTokenExpiryFromLocalStorage } from '../sessionCookie/accessSessionLocalStorage'; let isRequestInProgress = false; export const logout = (withRedirect: boolean = true, redirectUrl?: string) => { @@ -29,6 +30,7 @@ const invalidateCookies = ( redirectUrl?: string ) => { removeAllCookies(); + removeTokenExpiryFromLocalStorage(); if (withRedirect) { redirectAfterLogout(redirectUrl); } diff --git a/src/components/registration/autoLogin.ts b/src/components/registration/autoLogin.ts index 2b421b24d..1d3c4d78f 100644 --- a/src/components/registration/autoLogin.ts +++ b/src/components/registration/autoLogin.ts @@ -4,6 +4,7 @@ import { setTokenInCookie } from '../sessionCookie/accessSessionCookie'; import { config } from '../../resources/scripts/config'; import { generateCsrfToken } from '../../resources/scripts/helpers/generateCsrfToken'; import { encodeUsername } from '../../resources/scripts/helpers/encryptionHelpers'; +import { setTokens } from '../auth/auth'; export interface LoginData { data: { @@ -11,7 +12,9 @@ export interface LoginData { userId?: string; }; access_token?: string; + expires_in?: number; refresh_token?: string; + refresh_expires_in?: number; } export const autoLogin = ( @@ -27,12 +30,7 @@ export const autoLogin = ( encodeURIComponent(password) ) .then((response) => { - if (response.access_token) { - setTokenInCookie('keycloak', response.access_token); - } - if (response.refresh_token) { - setTokenInCookie('refreshToken', response.refresh_token); - } + setTokens(response); getRocketchatAccessToken(userHash, password) .then((response) => { diff --git a/src/components/sessionCookie/accessSessionLocalStorage.ts b/src/components/sessionCookie/accessSessionLocalStorage.ts new file mode 100644 index 000000000..f3e0acc05 --- /dev/null +++ b/src/components/sessionCookie/accessSessionLocalStorage.ts @@ -0,0 +1,33 @@ +export type LocalStorageKey = + | 'auth.access_token_valid_until' + | 'auth.refresh_token_valid_until'; + +export const getLocalStorageItem = (key: LocalStorageKey): string => { + return localStorage.getItem(key); +}; + +export const removeLocalStorageItem = (key: LocalStorageKey): void => { + localStorage.removeItem(key); +}; + +export const setTokenExpiryInLocalStorage = ( + key: LocalStorageKey, + expiresInMs: number +) => { + const validUntilTime = new Date().getTime() + expiresInMs * 1000; + localStorage.setItem(key, validUntilTime.toString()); +}; + +export const getTokenExpiryFromLocalStorage = () => ({ + accessTokenValidUntilTime: parseInt( + getLocalStorageItem('auth.access_token_valid_until') + ), + refreshTokenValidUntilTime: parseInt( + getLocalStorageItem('auth.refresh_token_valid_until') + ) +}); + +export const removeTokenExpiryFromLocalStorage = () => { + removeLocalStorageItem('auth.access_token_valid_until'); + removeLocalStorageItem('auth.refresh_token_valid_until'); +}; diff --git a/src/components/sessionCookie/refreshKeycloakAccessToken.ts b/src/components/sessionCookie/refreshKeycloakAccessToken.ts new file mode 100644 index 000000000..b9aaeb13a --- /dev/null +++ b/src/components/sessionCookie/refreshKeycloakAccessToken.ts @@ -0,0 +1,35 @@ +import { config } from '../../resources/scripts/config'; +import { LoginData } from '../registration/autoLogin'; +import { getTokenFromCookie } from './accessSessionCookie'; + +export const refreshKeycloakAccessToken = (): Promise => + new Promise((resolve, reject) => { + const refreshToken = getTokenFromCookie('refreshToken'); + const data = + 'refresh_token=' + + refreshToken + + '&client_id=app&grant_type=refresh_token'; + const url = config.endpoints.keycloakAccessToken; + + const req = new Request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'cache-control': 'no-cache' + }, + body: data + }); + + fetch(req) + .then((response) => { + if (response.status === 200) { + const data = response.json(); + resolve(data); + } else if (response.status === 401) { + reject(new Error('keycloakLogin')); + } + }) + .catch((error) => { + reject(new Error('keycloakLogin')); + }); + });