Skip to content

Commit

Permalink
Merge pull request #69 from CaritasDeutschland/feature-refresh-token
Browse files Browse the repository at this point in the history
feat: refresh keycloak access token until refresh token expires
  • Loading branch information
Johannes Becker authored Dec 2, 2020
2 parents e398a3a + b68bcaa commit 57e46d6
Show file tree
Hide file tree
Showing 9 changed files with 337 additions and 25 deletions.
4 changes: 2 additions & 2 deletions cypress/fixtures/auth.token.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
132 changes: 132 additions & 0 deletions cypress/integration/tokens.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 3 additions & 3 deletions cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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, {
Expand All @@ -102,6 +102,6 @@ Cypress.Commands.add(
});
cy.get('.button__primary').click();
cy.wait('@authToken');
cy.get('#appRoot');
cy.get('#appRoot').should('exist');
}
);
31 changes: 17 additions & 14 deletions src/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down
109 changes: 109 additions & 0 deletions src/components/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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();
}
});
};
2 changes: 2 additions & 0 deletions src/components/logout/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -29,6 +30,7 @@ const invalidateCookies = (
redirectUrl?: string
) => {
removeAllCookies();
removeTokenExpiryFromLocalStorage();
if (withRedirect) {
redirectAfterLogout(redirectUrl);
}
Expand Down
10 changes: 4 additions & 6 deletions src/components/registration/autoLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ 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: {
authToken?: string;
userId?: string;
};
access_token?: string;
expires_in?: number;
refresh_token?: string;
refresh_expires_in?: number;
}

export const autoLogin = (
Expand All @@ -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) => {
Expand Down
Loading

0 comments on commit 57e46d6

Please sign in to comment.